diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f045e91..5dcf86c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,9 +28,6 @@ jobs: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: cargo clippy --workspace --all-targets --all-features - # Also lint the default-feature config (registry-publish OFF) — this is what - # release binaries are built from, so it must stay clean too. - - run: cargo clippy --workspace --all-targets build: name: Build diff --git a/AGENTS.md b/AGENTS.md index ef4325f8..cdd9b983 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ Commands are divided into two categories: - Initialize `FastSkillService` first - Use shared service layer for operations -2. **Standalone commands** (init, install, publish, auth, registry) +2. **Standalone commands** (init, install, registry) - Execute without full service initialization - Avoid circular dependencies and overhead - Registry command has its own modular structure in `src/cli/commands/registry/` @@ -222,7 +222,6 @@ FastSkill resolves configuration in priority order: Defined in `Cargo.toml`: - `filesystem-storage` (default) - Local filesystem storage for skills -- `registry-publish` (default) - Publishing to registries with AWS S3 - `hot-reload` (optional) - File watching for automatic skill reloading Tests requiring optional features are skipped if features not enabled. diff --git a/CONTEXT.md b/CONTEXT.md index d7f63aaf..d0c847fd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,11 +53,8 @@ A diagnostic command that reports environment readiness — chiefly whether an * The distribution commands form an orthogonal pipeline, not overlapping verbs: -**Artifact**: -A packaged skill produced by `package` — a ZIP with change-detection/version-bump. Input to `publish`. - **Registry index**: -The catalog `publish` writes (alongside blobs) for an **http-registry** repository; consumed by `repos`/`search --remote`. FastSkill's *native* catalog format. +The on-disk NDJSON catalog read by `fastskill serve` and `registry search`; populated externally (e.g. by the platform operator) for an **http-registry** repository; consumed by `repos`/`search --remote`. FastSkill's *native* catalog format. **marketplace.json**: A *distinct, first-class* catalog produced by `marketplace create`, consumed by plugin-marketplace tooling (e.g. Claude Code plugin marketplaces). **Not** interchangeable with the **Registry index** — two real formats for two different consumers; do not conflate or collapse them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7225ffb..619ad176 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,7 +166,6 @@ fn test_command_output() { fastskill supports several feature flags that affect available functionality: - `filesystem-storage` (default): Enables local filesystem storage for skills -- `registry-publish` (default): Enables publishing skills to registries - `hot-reload` (optional): Enables file watching for automatic skill reloading during development #### Test matrix diff --git a/Cargo.lock b/Cargo.lock index 6e74d2cd..bc2ee98b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,7 +149,7 @@ dependencies = [ "async-trait", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", @@ -337,48 +337,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-config" -version = "1.8.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http 0.63.6", - "aws-smithy-json 0.62.5", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "hex", - "http 1.4.0", - "sha1", - "time", - "tokio", - "tracing", - "url", - "zeroize", -] - -[[package]] -name = "aws-credential-types" -version = "1.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -401,424 +359,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "aws-runtime" -version = "1.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" -dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "bytes-utils", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", -] - -[[package]] -name = "aws-sdk-s3" -version = "1.119.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-checksums", - "aws-smithy-eventstream", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "bytes", - "fastrand 2.4.1", - "hex", - "hmac 0.12.1", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "lru", - "percent-encoding", - "regex-lite", - "sha2 0.10.9", - "tracing", - "url", -] - -[[package]] -name = "aws-sdk-sso" -version = "1.98.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http 0.63.6", - "aws-smithy-json 0.62.5", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.100.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http 0.63.6", - "aws-smithy-json 0.62.5", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.103.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http 0.63.6", - "aws-smithy-json 0.62.5", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sigv4" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" -dependencies = [ - "aws-credential-types", - "aws-smithy-eventstream", - "aws-smithy-http 0.63.6", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "crypto-bigint 0.5.5", - "form_urlencoded", - "hex", - "hmac 0.13.0", - "http 0.2.12", - "http 1.4.0", - "p256", - "percent-encoding", - "ring", - "sha2 0.11.0", - "subtle", - "time", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-async" -version = "1.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-checksums" -version = "0.63.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" -dependencies = [ - "aws-smithy-http 0.62.6", - "aws-smithy-types", - "bytes", - "crc-fast", - "hex", - "http 0.2.12", - "http-body 0.4.6", - "md-5", - "pin-project-lite", - "sha1", - "sha2 0.10.9", - "tracing", -] - -[[package]] -name = "aws-smithy-eventstream" -version = "0.60.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] - -[[package]] -name = "aws-smithy-http" -version = "0.62.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" -dependencies = [ - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http" -version = "0.63.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http-client" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.9.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", - "hyper-util", - "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.38", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tower", - "tracing", -] - -[[package]] -name = "aws-smithy-json" -version = "0.61.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-json" -version = "0.62.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-observability" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" -dependencies = [ - "aws-smithy-runtime-api", -] - -[[package]] -name = "aws-smithy-query" -version = "0.60.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" -dependencies = [ - "aws-smithy-types", - "urlencoding", -] - -[[package]] -name = "aws-smithy-runtime" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" -dependencies = [ - "aws-smithy-async", - "aws-smithy-http 0.63.6", - "aws-smithy-http-client", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "fastrand 2.4.1", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.4.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "aws-smithy-types" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", - "tokio", - "tokio-util", -] - -[[package]] -name = "aws-smithy-xml" -version = "0.60.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" -dependencies = [ - "xmlparser", -] - -[[package]] -name = "aws-types" -version = "1.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" -dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "rustc_version", - "tracing", -] - [[package]] name = "axum" version = "0.8.9" @@ -887,12 +427,6 @@ dependencies = [ "syn", ] -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - [[package]] name = "base64" version = "0.13.1" @@ -911,22 +445,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.8.0" @@ -966,15 +484,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1016,16 +525,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - [[package]] name = "bzip2" version = "0.5.2" @@ -1100,7 +599,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", ] @@ -1184,12 +683,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "colorchoice" version = "1.0.5" @@ -1263,18 +756,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -1369,19 +850,6 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" -[[package]] -name = "crc-fast" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" -dependencies = [ - "crc", - "digest 0.10.7", - "rand 0.9.4", - "regex", - "rustversion", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1453,28 +921,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1485,24 +931,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "darling" version = "0.20.11" @@ -1616,16 +1044,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid 0.9.6", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1658,23 +1076,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", - "ctutils", -] - [[package]] name = "dirs" version = "5.0.1" @@ -1749,44 +1155,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der", - "elliptic-curve", - "rfc6979", - "signature", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct", - "crypto-bigint 0.4.9", - "der", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email_address" version = "0.2.9" @@ -1891,9 +1259,6 @@ dependencies = [ "anyhow", "assert_cmd", "async-trait", - "aws-config", - "aws-sdk-s3", - "base64 0.22.1", "chrono", "clap", "cli-framework", @@ -1914,7 +1279,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", @@ -1925,7 +1290,6 @@ dependencies = [ "url", "uuid", "walkdir", - "zip 0.6.6", ] [[package]] @@ -1935,10 +1299,7 @@ dependencies = [ "anyhow", "assert_cmd", "async-trait", - "aws-config", - "aws-sdk-s3", "axum", - "base64 0.22.1", "chrono", "cli-framework", "config", @@ -1947,7 +1308,6 @@ dependencies = [ "futures", "include_dir", "insta", - "multer", "notify", "num_cpus", "once_cell", @@ -1959,7 +1319,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", @@ -1987,16 +1347,6 @@ dependencies = [ "toml 0.8.23", ] -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "filetime" version = "0.2.27" @@ -2316,17 +1666,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "h2" version = "0.3.27" @@ -2377,8 +1716,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2420,28 +1757,13 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.2", + "digest", ] [[package]] @@ -2538,15 +1860,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "0.14.32" @@ -2602,7 +1915,6 @@ dependencies = [ "futures-util", "http 0.2.12", "hyper 0.14.32", - "log", "rustls 0.21.12", "tokio", "tokio-rustls 0.24.1", @@ -2618,7 +1930,6 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "rustls 0.23.38", - "rustls-native-certs", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -3132,15 +2443,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -3183,16 +2485,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "memchr" version = "2.8.0" @@ -3514,17 +2806,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "p256" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" -dependencies = [ - "ecdsa", - "elliptic-curve", - "sha2 0.10.9", -] - [[package]] name = "parking" version = "2.2.1" @@ -3572,8 +2853,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "digest", + "hmac", ] [[package]] @@ -3622,7 +2903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3631,22 +2912,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -4047,12 +3312,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - [[package]] name = "regex-syntax" version = "0.8.10" @@ -4194,17 +3453,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint 0.4.9", - "hmac 0.12.1", - "zeroize", -] - [[package]] name = "ring" version = "0.17.14" @@ -4532,20 +3780,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4714,7 +3948,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", + "digest", ] [[package]] @@ -4725,18 +3959,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "digest", ] [[package]] @@ -4786,16 +4009,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.9" @@ -4862,16 +4075,6 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.3" @@ -5073,7 +4276,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -5082,16 +4284,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5553,12 +4745,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -6336,12 +5522,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - [[package]] name = "xz2" version = "0.1.7" @@ -6507,7 +5687,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac 0.12.1", + "hmac", "indexmap", "lzma-rs", "memchr", diff --git a/README.md b/README.md index 63e5fb70..d40c7e99 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,6 @@ fastskill install --lock | `fastskill search "" --local` | Search installed skills | | `fastskill eval validate` | Validate eval configuration and checks | | `fastskill doctor` | Diagnose configuration and environment (e.g. whether semantic search is available) | -| `fastskill package` | Package skills for distribution | ## Documentation diff --git a/STYLE.md b/STYLE.md index 64498761..5410a2cc 100644 --- a/STYLE.md +++ b/STYLE.md @@ -540,7 +540,7 @@ error: Failed to connect to registry hint: Check your internet connection hint: Verify FASTSKILL_API_URL is set correctly -hint: Run 'fastskill auth login' to refresh authentication +hint: Set FASTSKILL_API_TOKEN to refresh authentication ``` **Integration with error template:** @@ -697,7 +697,7 @@ match (is_tty(), is_piped()) { ### FS101: Authentication Required **Description:** Registry access requires authentication. -**Recovery:** Run `fastskill auth login` to authenticate. +**Recovery:** Set the `FASTSKILL_API_TOKEN` environment variable to authenticate. ### FS201: Skill Not Found **Description:** Requested skill does not exist in registry. @@ -733,7 +733,7 @@ error: Failed to authenticate with registry [FS101] The registry at https://registry.example.com requires authentication, but no valid token was found. -hint: Run 'fastskill auth login' to authenticate +hint: Set FASTSKILL_API_TOKEN environment variable to authenticate hint: Set FASTSKILL_API_TOKEN environment variable hint: Check that your token hasn't expired ``` diff --git a/crates/fastskill-cli/Cargo.toml b/crates/fastskill-cli/Cargo.toml index f1524281..8e03a5ef 100644 --- a/crates/fastskill-cli/Cargo.toml +++ b/crates/fastskill-cli/Cargo.toml @@ -57,9 +57,6 @@ tokio-util.workspace = true walkdir.workspace = true fs2.workspace = true -# Archive handling -zip.workspace = true - # HTTP client reqwest.workspace = true @@ -67,9 +64,6 @@ reqwest.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -# Base64 encoding/decoding for JWT parsing (used in auth_config) -base64.workspace = true - # SHA256 hashing sha2.workspace = true @@ -88,10 +82,6 @@ dirs.workspace = true # Semantic versioning semver.workspace = true -# AWS S3 storage (optional; only compiled with registry publishing) -aws-sdk-s3 = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } - # Vendored OpenSSL — required for musl cross-compilation because cli-framework and # aikit-sdk depend on reqwest without default-features = false, which unifies # native-tls into the build. The vendored feature compiles OpenSSL from source @@ -100,7 +90,6 @@ openssl = { version = "0.10", optional = true, features = ["vendored"] } [features] default = [] -registry-publish = ["fastskill-core/registry-publish", "dep:aws-sdk-s3", "dep:aws-config"] vendored-openssl = ["dep:openssl"] [dev-dependencies] diff --git a/crates/fastskill-cli/src/auth_config.rs b/crates/fastskill-cli/src/auth_config.rs deleted file mode 100644 index 7044246e..00000000 --- a/crates/fastskill-cli/src/auth_config.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Authentication configuration management for CLI -//! Handles storage and retrieval of JWT tokens per registry - -use crate::error::{CliError, CliResult}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use chrono::{DateTime, Utc}; -use dirs::config_dir; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -#[cfg(feature = "registry-publish")] -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Authentication configuration structure -#[derive(Debug, Clone, Serialize, Deserialize)] -struct AuthConfig { - /// Registry-specific authentication entries - #[serde(default)] - registry: HashMap, -} - -/// Authentication info for a specific registry -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RegistryAuth { - token: String, - role: String, - username: Option, - expires_at: Option, - last_refresh: Option, -} - -/// JWT claims structure for expiration checking -#[derive(Debug, serde::Deserialize)] -struct Claims { - exp: usize, - sub: Option, - #[allow(dead_code)] // May be used in future for role-based access control - role: Option, -} - -/// Get the config file path -fn get_config_path() -> CliResult { - // Check for FASTSKILL_CONFIG_DIR environment variable first - if let Ok(env_config_dir) = std::env::var("FASTSKILL_CONFIG_DIR") { - return Ok(PathBuf::from(env_config_dir).join("config.toml")); - } - - let config_dir = config_dir() - .ok_or_else(|| CliError::Config("Failed to determine config directory".to_string()))?; - let fastskill_dir = config_dir.join("fastskill"); - Ok(fastskill_dir.join("config.toml")) -} - -/// Normalize registry URL for consistent storage -fn normalize_registry_url(url: &str) -> String { - let mut normalized = url.trim().to_string(); - // Remove trailing slash - if normalized.ends_with('/') { - normalized.pop(); - } - normalized -} - -/// Load authentication configuration from file -fn load_auth_config() -> CliResult { - let config_path = get_config_path()?; - - if !config_path.exists() { - return Ok(AuthConfig { - registry: HashMap::new(), - }); - } - - let content = fs::read_to_string(&config_path).map_err(|e| { - CliError::Config(format!( - "Failed to read config file {}: {}", - config_path.display(), - e - )) - })?; - - let config: AuthConfig = toml::from_str(&content).map_err(|e| { - CliError::Config(format!( - "Failed to parse config file {}: {}", - config_path.display(), - e - )) - })?; - - Ok(config) -} - -/// Save authentication configuration to file -fn save_auth_config(config: &AuthConfig) -> CliResult<()> { - let config_path = get_config_path()?; - let config_dir = config_path - .parent() - .ok_or_else(|| CliError::Config("Invalid config path".to_string()))?; - - // Create config directory if it doesn't exist - fs::create_dir_all(config_dir) - .map_err(|e| CliError::Config(format!("Failed to create config directory: {}", e)))?; - - // Serialize to TOML - let content = toml::to_string_pretty(config) - .map_err(|e| CliError::Config(format!("Failed to serialize config: {}", e)))?; - - // Write to file with secure permissions (0o600) - fs::write(&config_path, content).map_err(|e| { - CliError::Config(format!( - "Failed to write config file {}: {}", - config_path.display(), - e - )) - })?; - - // Set file permissions to 0o600 (read/write for owner only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&config_path) - .map_err(|e| CliError::Config(format!("Failed to get file metadata: {}", e)))? - .permissions(); - perms.set_mode(0o600); - fs::set_permissions(&config_path, perms) - .map_err(|e| CliError::Config(format!("Failed to set file permissions: {}", e)))?; - } - - Ok(()) -} - -/// Decode JWT token without verification (for reading claims only) -/// JWTs are base64url-encoded JSON, format: header.payload.signature -fn decode_token_unsafe(token: &str) -> Option { - // Split JWT into parts - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return None; - } - - // Decode the payload (second part) - let payload = parts[1]; - - // Base64url decode - let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; - let json_str = String::from_utf8(decoded).ok()?; - - // Parse JSON into Claims - serde_json::from_str::(&json_str).ok() -} - -/// Check if a JWT token is expired or expiring soon -#[cfg(feature = "registry-publish")] -fn is_token_expired_or_expiring_soon(token: &str, buffer_seconds: u64) -> CliResult { - match decode_token_unsafe(token) { - Some(claims) => { - let exp = claims.exp as u64; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| CliError::Config(format!("Time error: {}", e)))? - .as_secs(); - - // Check if expired or expiring within buffer_seconds - Ok(exp <= now + buffer_seconds) - } - None => { - // If we can't decode, assume it's invalid/expired - Ok(true) - } - } -} - -/// Get token expiration time from JWT -fn get_token_expiration(token: &str) -> Option> { - decode_token_unsafe(token).and_then(|claims| { - let exp = claims.exp as u64; - DateTime::from_timestamp(exp as i64, 0) - }) -} - -/// Get username from JWT token -fn get_token_username(token: &str) -> Option { - decode_token_unsafe(token).and_then(|claims| claims.sub) -} - -/// Get role from JWT token -#[allow(dead_code)] // May be used in future for role-based access control -fn get_token_role(token: &str) -> Option { - decode_token_unsafe(token).and_then(|claims| claims.role) -} - -/// Get authentication token for a specific registry -/// Priority: FASTSKILL_API_TOKEN env var > config file -#[allow(dead_code)] // May be used in future -pub fn get_token_for_registry(registry_url: &str) -> CliResult> { - // Priority 1: Environment variable - if let Ok(token) = std::env::var("FASTSKILL_API_TOKEN") { - return Ok(Some(token)); - } - - // Priority 2: Config file - let normalized_url = normalize_registry_url(registry_url); - let config = load_auth_config()?; - - if let Some(auth) = config.registry.get(&normalized_url) { - Ok(Some(auth.token.clone())) - } else { - Ok(None) - } -} - -/// Get token with automatic refresh if needed -#[cfg(feature = "registry-publish")] -pub async fn get_token_with_refresh(registry_url: &str) -> CliResult> { - // Check environment variable first - if let Ok(token) = std::env::var("FASTSKILL_API_TOKEN") { - return Ok(Some(token)); - } - - let normalized_url = normalize_registry_url(registry_url); - let mut config = load_auth_config()?; - - if let Some(auth) = config.registry.get(&normalized_url) { - // Check expiration using stored expires_at or decode token - let is_expired = if let Some(expires_at_str) = &auth.expires_at { - // Use stored expiration time (more efficient) - if let Ok(expires_at) = DateTime::parse_from_rfc3339(expires_at_str) { - let now = Utc::now(); - let buffer = chrono::Duration::seconds(300); // 5 minutes - expires_at.with_timezone(&Utc) <= now + buffer - } else { - // Fallback to decoding token - is_token_expired_or_expiring_soon(&auth.token, 300)? - } - } else { - // No stored expiration, decode token - is_token_expired_or_expiring_soon(&auth.token, 300)? - }; - - if is_expired { - // Token expired, try to refresh - println!( - "Token expired for registry: {}, refreshing...", - normalized_url - ); - - // Refresh token by calling /auth/token endpoint - let refresh_result = refresh_token_for_registry(&normalized_url, &auth.role).await; - - match refresh_result { - Ok(new_token) => { - // Update config with new token - let username = get_token_username(&new_token); - let expires_at = get_token_expiration(&new_token).map(|dt| dt.to_rfc3339()); - - let new_auth = RegistryAuth { - token: new_token.clone(), - role: auth.role.clone(), - username, - expires_at, - last_refresh: Some(Utc::now().to_rfc3339()), - }; - - config.registry.insert(normalized_url.clone(), new_auth); - save_auth_config(&config)?; - - println!("✓ Token refreshed"); - Ok(Some(new_token)) - } - Err(e) => { - eprintln!( - "Token refresh failed for registry: {}. Run `fastskill auth login` to re-authenticate.", - normalized_url - ); - Err(e) - } - } - } else { - // Token is still valid - Ok(Some(auth.token.clone())) - } - } else { - Ok(None) - } -} - -/// Refresh token for a registry by calling /auth/token endpoint -#[cfg(feature = "registry-publish")] -async fn refresh_token_for_registry(registry_url: &str, role: &str) -> CliResult { - let client = reqwest::Client::new(); - let token_url = format!("{}/auth/token", registry_url); - - let request_body = serde_json::json!({ - "role": role - }); - - let response = client - .post(&token_url) - .json(&request_body) - .send() - .await - .map_err(|e| CliError::Config(format!("Failed to refresh token: {}", e)))?; - - if !response.status().is_success() { - return Err(CliError::Config(format!( - "Token refresh failed with status {}: {}", - response.status(), - response.text().await.unwrap_or_default() - ))); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| CliError::Config(format!("Failed to parse token response: {}", e)))?; - - let token = json - .get("data") - .and_then(|d| d.get("token")) - .and_then(|t| t.as_str()) - .ok_or_else(|| CliError::Config("Invalid token response format".to_string()))?; - - Ok(token.to_string()) -} - -/// Store authentication token for a registry -pub async fn set_token_for_registry(registry_url: &str, token: &str, role: &str) -> CliResult<()> { - let normalized_url = normalize_registry_url(registry_url); - let mut config = load_auth_config()?; - - let username = get_token_username(token); - let expires_at = get_token_expiration(token).map(|dt| dt.to_rfc3339()); - - let auth = RegistryAuth { - token: token.to_string(), - role: role.to_string(), - username, - expires_at, - last_refresh: Some(Utc::now().to_rfc3339()), - }; - - config.registry.insert(normalized_url, auth); - save_auth_config(&config)?; - - Ok(()) -} - -/// Remove authentication token for a registry -pub fn remove_token_for_registry(registry_url: &str) -> CliResult<()> { - let normalized_url = normalize_registry_url(registry_url); - let mut config = load_auth_config()?; - - config.registry.remove(&normalized_url); - save_auth_config(&config)?; - - Ok(()) -} - -/// Get authentication info for a registry (for whoami command) -pub fn get_auth_info_for_registry(registry_url: &str) -> CliResult> { - let normalized_url = normalize_registry_url(registry_url); - let config = load_auth_config()?; - - if let Some(auth) = config.registry.get(&normalized_url) { - Ok(Some(AuthInfo { - registry_url: normalized_url, - username: auth.username.clone(), - role: auth.role.clone(), - expires_at: auth.expires_at.clone(), - last_refresh: auth.last_refresh.clone(), - })) - } else { - Ok(None) - } -} - -/// Authentication information structure -#[derive(Debug, Clone)] -pub struct AuthInfo { - pub registry_url: String, - pub username: Option, - pub role: String, - pub expires_at: Option, - pub last_refresh: Option, -} diff --git a/crates/fastskill-cli/src/commands/auth.rs b/crates/fastskill-cli/src/commands/auth.rs deleted file mode 100644 index 03733196..00000000 --- a/crates/fastskill-cli/src/commands/auth.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! Authentication commands for CLI - -use crate::auth_config; -use crate::error::{CliError, CliResult}; -use crate::utils::messages; -use clap::{Args, Subcommand}; -use cli_framework::command::{FromArgValueMap, IntoCommandSpec}; -use cli_framework::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality}; -use cli_framework::spec::command_tree::CommandSpec; -use cli_framework::spec::value::ArgValue; -use std::collections::HashMap; - -/// Authentication command arguments -#[derive(Debug, Args)] -#[command(after_help = "Examples:\n fastskill auth login\n fastskill auth whoami")] -pub struct AuthArgs { - #[command(subcommand)] - pub command: AuthCommand, -} - -#[derive(Debug, Subcommand)] -pub enum AuthCommand { - /// Login to a registry and store authentication token - #[command( - after_help = "Examples:\n fastskill auth login\n fastskill auth login --registry https://registry.example.com" - )] - Login(LoginArgs), - /// Logout from a registry (remove stored token) - #[command( - after_help = "Examples:\n fastskill auth logout\n fastskill auth logout --registry https://registry.example.com" - )] - Logout(LogoutArgs), - /// Show current authentication status - #[command(after_help = "Examples:\n fastskill auth whoami")] - Whoami(WhoamiArgs), -} - -/// Login command arguments -#[derive(Debug, Args)] -pub struct LoginArgs { - /// Registry URL to authenticate with - #[arg(long)] - pub registry: Option, - - /// Role for the token (default: manager) - #[arg(long, default_value = "manager")] - pub role: String, -} - -impl IntoCommandSpec for LoginArgs { - fn command_spec() -> CommandSpec { - CommandSpec { - summary: "Login to a skill registry", - syntax: Some("auth login [OPTIONS]"), - category: Some("auth"), - args: vec![ - ArgSpec { - name: "registry", - kind: ArgKind::Option, - long: Some("registry"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Registry URL to authenticate with", - ..Default::default() - }, - ArgSpec { - name: "role", - kind: ArgKind::Option, - long: Some("role"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Role for the token (default: manager)", - default: Some(ArgValue::Str("manager".to_string())), - ..Default::default() - }, - ], - ..Default::default() - } - } -} - -impl FromArgValueMap for LoginArgs { - fn from_arg_value_map(map: &HashMap) -> Self { - Self { - registry: map.get("registry").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - role: map - .get("role") - .and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }) - .unwrap_or_else(|| "manager".to_string()), - } - } -} - -/// Logout command arguments -#[derive(Debug, Args)] -pub struct LogoutArgs { - /// Registry URL to logout from - #[arg(long)] - pub registry: Option, -} - -impl IntoCommandSpec for LogoutArgs { - fn command_spec() -> CommandSpec { - CommandSpec { - summary: "Logout from a skill registry", - syntax: Some("auth logout [OPTIONS]"), - category: Some("auth"), - args: vec![ArgSpec { - name: "registry", - kind: ArgKind::Option, - long: Some("registry"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Registry URL to logout from", - ..Default::default() - }], - ..Default::default() - } - } -} - -impl FromArgValueMap for LogoutArgs { - fn from_arg_value_map(map: &HashMap) -> Self { - Self { - registry: map.get("registry").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - } - } -} - -/// Whoami command arguments -#[derive(Debug, Args)] -pub struct WhoamiArgs { - /// Registry URL to check (default: from FASTSKILL_API_URL or http://localhost:8080) - #[arg(long)] - pub registry: Option, -} - -impl IntoCommandSpec for WhoamiArgs { - fn command_spec() -> CommandSpec { - CommandSpec { - summary: "Show current authenticated user", - syntax: Some("auth whoami [OPTIONS]"), - category: Some("auth"), - args: vec![ArgSpec { - name: "registry", - kind: ArgKind::Option, - long: Some("registry"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Registry URL to check (default: from FASTSKILL_API_URL or http://localhost:8080)", - ..Default::default() - }], - ..Default::default() - } - } -} - -impl FromArgValueMap for WhoamiArgs { - fn from_arg_value_map(map: &HashMap) -> Self { - Self { - registry: map.get("registry").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - } - } -} - -/// Execute login command -pub async fn execute_login(args: LoginArgs) -> CliResult<()> { - // Determine registry URL - let registry_url = args - .registry - .or_else(|| std::env::var("FASTSKILL_API_URL").ok()) - .unwrap_or_else(|| "http://localhost:8080".to_string()); - - println!("Logging in to registry: {}", registry_url); - - // Call /auth/token endpoint - let client = reqwest::Client::new(); - let token_url = format!("{}/auth/token", registry_url); - - let request_body = serde_json::json!({ - "role": args.role - }); - - let response = client - .post(&token_url) - .json(&request_body) - .send() - .await - .map_err(|e| CliError::Config(format!("Failed to connect to registry: {}", e)))?; - - if !response.status().is_success() { - return Err(CliError::Config(format!( - "Login failed with status {}: {}", - response.status(), - response.text().await.unwrap_or_default() - ))); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| CliError::Config(format!("Failed to parse response: {}", e)))?; - - let token = json - .get("data") - .and_then(|d| d.get("token")) - .and_then(|t| t.as_str()) - .ok_or_else(|| CliError::Config("Invalid token response format".to_string()))?; - - // Store token in config - auth_config::set_token_for_registry(®istry_url, token, &args.role).await?; - - println!( - "{}", - messages::ok(&format!("Successfully logged in to {}", registry_url)) - ); - println!("Token saved and will be used automatically"); - - Ok(()) -} - -/// Execute logout command -pub fn execute_logout(args: LogoutArgs) -> CliResult<()> { - // Determine registry URL - let registry_url = args - .registry - .or_else(|| std::env::var("FASTSKILL_API_URL").ok()) - .unwrap_or_else(|| "http://localhost:8080".to_string()); - - // Remove token from config - auth_config::remove_token_for_registry(®istry_url)?; - - println!( - "{}", - messages::ok(&format!("Successfully logged out from {}", registry_url)) - ); - - Ok(()) -} - -/// Execute whoami command -pub fn execute_whoami(args: WhoamiArgs) -> CliResult<()> { - // Determine registry URL - let registry_url = args - .registry - .or_else(|| std::env::var("FASTSKILL_API_URL").ok()) - .unwrap_or_else(|| "http://localhost:8080".to_string()); - - // Check if using environment variable - if std::env::var("FASTSKILL_API_TOKEN").is_ok() { - println!("Using token from FASTSKILL_API_TOKEN environment variable"); - println!("Registry: {}", registry_url); - println!("Note: Environment variable tokens don't store user info"); - return Ok(()); - } - - // Get auth info from config - match auth_config::get_auth_info_for_registry(®istry_url)? { - Some(info) => { - println!("Registry: {}", info.registry_url); - if let Some(username) = &info.username { - println!("Username: {}", username); - } - println!("Role: {}", info.role); - if let Some(expires_at) = &info.expires_at { - println!("Token expires: {}", expires_at); - } - if let Some(last_refresh) = &info.last_refresh { - println!("Last refresh: {}", last_refresh); - } - } - None => { - println!("Not logged in to registry: {}", registry_url); - println!("Run `fastskill auth login` to authenticate"); - } - } - - Ok(()) -} diff --git a/crates/fastskill-cli/src/commands/mod.rs b/crates/fastskill-cli/src/commands/mod.rs index a0d44a05..dcd41092 100644 --- a/crates/fastskill-cli/src/commands/mod.rs +++ b/crates/fastskill-cli/src/commands/mod.rs @@ -2,7 +2,6 @@ pub mod add; pub mod analyze; -pub mod auth; pub mod common; pub mod doctor; pub mod eval; @@ -10,9 +9,6 @@ pub mod init; pub mod install; pub mod list; pub mod marketplace; -pub mod package; -#[cfg(feature = "registry-publish")] -pub mod publish; pub mod read; pub mod registry; pub mod reindex; diff --git a/crates/fastskill-cli/src/commands/package.rs b/crates/fastskill-cli/src/commands/package.rs deleted file mode 100644 index f0719488..00000000 --- a/crates/fastskill-cli/src/commands/package.rs +++ /dev/null @@ -1,732 +0,0 @@ -//! Package command implementation - -use crate::error::{CliError, CliResult}; -use crate::utils::messages; -use cli_framework::command::{FromArgValueMap, IntoCommandSpec}; -use cli_framework::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality}; -use cli_framework::spec::command_tree::CommandSpec; -use cli_framework::spec::value::ArgValue; -use fastskill_core::core::build_cache::BuildCache; -use fastskill_core::core::change_detection::{ - calculate_skill_hash, detect_changed_skills_git, detect_changed_skills_hash, -}; -use fastskill_core::core::version_bump::{ - bump_version, get_current_version, parse_version, update_skill_version, BumpType, -}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use tracing::info; -use walkdir::WalkDir; - -/// Package skills into ZIP artifacts -/// -/// Reads skill metadata from skill-project.toml [metadata] section (skill-level context). -/// Includes skill dependencies from skill-project.toml [dependencies] if present. -/// -/// PRESETS (recommended for common workflows): -/// fastskill package auto # Auto-detect changed skills -/// fastskill package skill # Package specific skill(s) -/// -/// ADVANCED OPTIONS (for power users): -/// fastskill package --detect-changes # Hash-based change detection -/// fastskill package --git-diff base head # Git-based change detection -/// fastskill package --skills id1 id2 # Package specific skills -/// fastskill package --force # Package all skills -/// -/// Note: the `evals/` directory is omitted from packaged skill ZIPs; exclusion is enforced in -/// `fastskill_core::core::packaging` when building archives, not in this CLI module. -#[derive(Debug)] -pub struct PackageArgs { - // ===== Advanced options (grouped for help clarity) ===== - /// Auto-detect changed skills (hash-based) - pub detect_changes: bool, - - /// Use git diff for change detection (format: base_ref head_ref) - pub git_diff: Option>, - - /// Package specific skills (skill IDs) - pub skills: Option>, - - /// Bump version type (major, minor, patch) - pub bump: Option, - - /// Auto-detect bump type from changes - pub auto_bump: bool, - - /// Output directory for artifacts - pub output: PathBuf, - - /// Force package all skills regardless of changes - pub force: bool, - - /// Dry run (show what would be packaged) - pub dry_run: bool, - - /// Skills directory to scan - pub skills_dir: PathBuf, - - /// Recursively scan skills directory for nested skills - pub recursive: bool, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BumpTypeArg { - Major, - Minor, - Patch, -} - -impl From for BumpType { - fn from(arg: BumpTypeArg) -> Self { - match arg { - BumpTypeArg::Major => BumpType::Major, - BumpTypeArg::Minor => BumpType::Minor, - BumpTypeArg::Patch => BumpType::Patch, - } - } -} - -/// Immutable execution plan derived from parsed args and preset. -/// Conflicting flag combinations are structurally impossible once constructed. -#[derive(Debug)] -pub(crate) struct PackagePlan { - pub skills: Option>, - pub bump: Option, - pub detect_changes: bool, - pub force: bool, -} - -impl PackagePlan { - fn from_args(args: &PackageArgs) -> Self { - PackagePlan { - skills: args.skills.clone(), - bump: args.bump.clone(), - detect_changes: args.detect_changes, - force: args.force, - } - } -} - -impl IntoCommandSpec for PackageArgs { - fn command_spec() -> CommandSpec { - CommandSpec { - summary: "Package skills into ZIP artifacts", - syntax: Some("package [OPTIONS]"), - category: Some("packaging"), - args: vec![ - ArgSpec { - name: "detect-changes", - kind: ArgKind::Flag, - long: Some("detect-changes"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Auto-detect changed skills (hash-based)", - ..Default::default() - }, - ArgSpec { - name: "git-diff", - kind: ArgKind::Option, - long: Some("git-diff"), - value_type: ArgValueType::String, - cardinality: Cardinality::Repeated, - help: "Use git diff for change detection (BASE HEAD)", - ..Default::default() - }, - ArgSpec { - name: "skills", - kind: ArgKind::Option, - long: Some("skills"), - value_type: ArgValueType::String, - cardinality: Cardinality::Repeated, - help: "Package specific skills (skill IDs)", - ..Default::default() - }, - ArgSpec { - name: "bump", - kind: ArgKind::Option, - long: Some("bump"), - value_type: ArgValueType::Enum(vec!["major", "minor", "patch"]), - cardinality: Cardinality::Optional, - help: "Bump version type (major, minor, patch)", - ..Default::default() - }, - ArgSpec { - name: "auto-bump", - kind: ArgKind::Flag, - long: Some("auto-bump"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Auto-detect bump type from changes", - ..Default::default() - }, - ArgSpec { - name: "output", - kind: ArgKind::Option, - long: Some("output"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Output directory for artifacts", - default: Some(ArgValue::Str("./artifacts".to_string())), - ..Default::default() - }, - ArgSpec { - name: "force", - kind: ArgKind::Flag, - long: Some("force"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Force package all skills regardless of changes", - ..Default::default() - }, - ArgSpec { - name: "dry-run", - kind: ArgKind::Flag, - long: Some("dry-run"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Dry run (show what would be packaged)", - ..Default::default() - }, - ArgSpec { - name: "skills-dir", - kind: ArgKind::Option, - long: Some("skills-dir"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Skills directory to scan", - default: Some(ArgValue::Str("./skills".to_string())), - ..Default::default() - }, - ArgSpec { - name: "recursive", - kind: ArgKind::Flag, - long: Some("recursive"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Recursively scan skills directory for nested skills", - ..Default::default() - }, - ], - ..Default::default() - } - } -} - -#[allow(clippy::panic)] -impl FromArgValueMap for PackageArgs { - fn from_arg_value_map(map: &HashMap) -> Self { - Self { - detect_changes: matches!(map.get("detect-changes"), Some(ArgValue::Bool(true))), - git_diff: match map.get("git-diff") { - Some(ArgValue::List(items)) => { - let strings: Vec = items - .iter() - .filter_map(|i| { - if let ArgValue::Str(s) = i { - Some(s.clone()) - } else { - None - } - }) - .collect(); - if strings.is_empty() { - None - } else { - Some(strings) - } - } - _ => None, - }, - skills: match map.get("skills") { - Some(ArgValue::List(items)) => { - let strings: Vec = items - .iter() - .filter_map(|i| { - if let ArgValue::Str(s) = i { - Some(s.clone()) - } else { - None - } - }) - .collect(); - if strings.is_empty() { - None - } else { - Some(strings) - } - } - _ => None, - }, - bump: map.get("bump").and_then(|v| match v { - ArgValue::Str(s) | ArgValue::Enum(s) => match s.as_str() { - "major" => Some(BumpTypeArg::Major), - "minor" => Some(BumpTypeArg::Minor), - "patch" => Some(BumpTypeArg::Patch), - _ => None, - }, - _ => None, - }), - auto_bump: matches!(map.get("auto-bump"), Some(ArgValue::Bool(true))), - output: map - .get("output") - .and_then(|v| { - if let ArgValue::Str(s) = v { - Some(PathBuf::from(s)) - } else { - None - } - }) - .unwrap_or_else(|| PathBuf::from("./artifacts")), - force: matches!(map.get("force"), Some(ArgValue::Bool(true))), - dry_run: matches!(map.get("dry-run"), Some(ArgValue::Bool(true))), - skills_dir: map - .get("skills-dir") - .and_then(|v| { - if let ArgValue::Str(s) = v { - Some(PathBuf::from(s)) - } else { - None - } - }) - .unwrap_or_else(|| PathBuf::from("./skills")), - recursive: matches!(map.get("recursive"), Some(ArgValue::Bool(true))), - } - } -} - -/// Validate package arguments for conflicting preset and manual flags (PKG_001) -fn validate_package_args(_args: &PackageArgs) -> CliResult<()> { - Ok(()) -} - -pub async fn execute_package(args: PackageArgs) -> CliResult<()> { - info!("Starting package command"); - - // Validate and apply preset if provided - validate_package_args(&args)?; - - // Derive execution plan from args and preset - let plan = PackagePlan::from_args(&args); - - // Determine which skills to package - let skills_to_package = if let Some(skill_ids) = plan.skills { - skill_ids - } else if plan.force { - // Package all skills - if args.recursive { - get_all_skills_recursive(&args.skills_dir)? - } else { - get_all_skills_shallow(&args.skills_dir)? - } - } else if let Some(ref git_diff) = args.git_diff { - if git_diff.len() != 2 { - return Err(CliError::Validation( - "git-diff requires exactly 2 arguments: base_ref head_ref".to_string(), - )); - } - detect_changed_skills_git(&git_diff[0], &git_diff[1], &args.skills_dir) - .map_err(|e| CliError::Validation(format!("Git change detection failed: {}", e)))? - } else if plan.detect_changes { - // Load build cache - let cache_path = PathBuf::from(".fastskill/build-cache.json"); - let cache = BuildCache::load(&cache_path) - .map_err(|e| CliError::Validation(format!("Failed to load build cache: {}", e)))?; - - detect_changed_skills_hash(&args.skills_dir, &cache) - .map_err(|e| CliError::Validation(format!("Hash change detection failed: {}", e)))? - } else { - return Err(CliError::Validation( - "Must specify --skills, --force, --detect-changes, or --git-diff".to_string(), - )); - }; - - if skills_to_package.is_empty() { - println!("{}", messages::info("No skills to package")); - return Ok(()); - } - - println!( - "{}", - messages::info(&format!("Packaging {} skill(s)", skills_to_package.len())) - ); - - // Load build cache - let cache_path = PathBuf::from(".fastskill/build-cache.json"); - let mut cache = BuildCache::load(&cache_path) - .map_err(|e| CliError::Validation(format!("Failed to load build cache: {}", e)))?; - - let mut packaged_artifacts = Vec::new(); - - for skill_id in &skills_to_package { - let skill_path = args.skills_dir.join(skill_id); - - if !skill_path.exists() { - println!( - "{}", - messages::warning(&format!( - "Skill directory not found: {}", - skill_path.display() - )) - ); - continue; - } - - // Get current version - let current_version_str = get_current_version(&skill_path) - .map_err(|e| { - CliError::Validation(format!("Failed to get version for {}: {}", skill_id, e)) - })? - .unwrap_or_else(|| "1.0.0".to_string()); - - // Parse and bump version if needed - let new_version = if let Some(ref bump_type) = plan.bump { - let current_version = parse_version(¤t_version_str).map_err(|e| { - CliError::Validation(format!("Invalid version '{}': {}", current_version_str, e)) - })?; - let bumped = bump_version(¤t_version, (*bump_type).clone().into()); - bumped.to_string() - } else if args.auto_bump { - // Auto-bump: default to patch for now - // TODO: Analyze changes to determine bump type - let current_version = parse_version(¤t_version_str).map_err(|e| { - CliError::Validation(format!("Invalid version '{}': {}", current_version_str, e)) - })?; - let bumped = bump_version(¤t_version, BumpType::Patch); - bumped.to_string() - } else { - current_version_str.clone() - }; - - if args.dry_run { - println!( - " Would package: {} (version: {} -> {})", - skill_id, current_version_str, new_version - ); - continue; - } - - // Update skill-project.toml with new version if it changed - if new_version != current_version_str { - update_skill_version(&skill_path, &new_version) - .map_err(|e| CliError::Validation(format!("Failed to update version: {}", e)))?; - println!(" Updated version: {} -> {}", skill_id, new_version); - } - - // Package skill (pass skill_id to preserve scoped names like "swe/codeguardian") - let artifact_path = fastskill_core::core::packaging::package_skill_with_id( - &skill_path, - &args.output, - &new_version, - Some(skill_id), - ) - .map_err(|e| CliError::Validation(format!("Failed to package {}: {}", skill_id, e)))?; - - // Calculate hash - let hash = calculate_skill_hash(&skill_path) - .map_err(|e| CliError::Validation(format!("Failed to calculate hash: {}", e)))?; - - // Get git commit if available - let git_commit = get_git_commit().ok(); - - // Update cache - cache.update_skill( - skill_id, - &new_version, - &hash, - &artifact_path, - git_commit.as_deref(), - ); - - packaged_artifacts.push(artifact_path.clone()); - println!( - "{}", - messages::ok(&format!( - "Packaged: {} -> {}", - skill_id, - artifact_path.display() - )) - ); - } - - // Save cache - if !args.dry_run { - cache - .save(&cache_path) - .map_err(|e| CliError::Validation(format!("Failed to save build cache: {}", e)))?; - } - - println!(); - println!( - "{}", - messages::ok(&format!( - "Packaged {} artifact(s)", - packaged_artifacts.len() - )) - ); - for artifact in &packaged_artifacts { - println!(" {}", artifact.display()); - } - - Ok(()) -} - -fn get_all_skills_shallow(skills_dir: &Path) -> CliResult> { - if !skills_dir.exists() { - return Ok(Vec::new()); - } - - let mut skills = Vec::new(); - let entries = std::fs::read_dir(skills_dir).map_err(CliError::Io)?; - - for entry in entries { - let entry = entry.map_err(CliError::Io)?; - let path = entry.path(); - - if path.is_dir() { - if let Some(skill_id) = path.file_name().and_then(|n| n.to_str()) { - if path.join("SKILL.md").exists() { - skills.push(skill_id.to_string()); - } - } - } - } - - Ok(skills) -} - -fn get_all_skills_recursive(skills_dir: &Path) -> CliResult> { - if !skills_dir.exists() { - return Ok(Vec::new()); - } - - let mut skills = Vec::new(); - let skills_dir_canonical = skills_dir.canonicalize().map_err(CliError::Io)?; - - for entry in WalkDir::new(skills_dir) - .min_depth(1) - .max_depth(usize::MAX) - .follow_links(false) - { - let entry = entry.map_err(|e| { - CliError::Io( - e.into_io_error() - .unwrap_or_else(|| std::io::Error::other("WalkDir error")), - ) - })?; - let path = entry.path(); - - // Check if this directory contains SKILL.md - if path.is_dir() && path.join("SKILL.md").exists() { - // Compute relative path from skills_dir to this skill directory - let path_canonical = path.canonicalize().map_err(CliError::Io)?; - let relative_path = - path_canonical - .strip_prefix(&skills_dir_canonical) - .map_err(|_| { - CliError::Validation(format!( - "Failed to compute relative path for: {}", - path.display() - )) - })?; - - // Skip hidden directories and tooling directories (check relative path components) - let should_skip = relative_path.components().any(|c| { - if let std::path::Component::Normal(name) = c { - name.to_string_lossy().starts_with('.') - } else { - false - } - }); - if should_skip { - continue; - } - - // Convert to string with forward slashes (normalize path separators) - let skill_id = relative_path.to_string_lossy().replace('\\', "/"); - skills.push(skill_id.to_string()); - } - } - - Ok(skills) -} - -fn get_git_commit() -> Result { - use std::process::Command; - - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .map_err(|e| CliError::Validation(format!("Failed to execute git: {}", e)))?; - - if !output.status.success() { - return Err(CliError::Validation( - "Failed to get git commit hash".to_string(), - )); - } - - let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(commit_hash) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[tokio::test] - async fn test_execute_package_nonexistent_skills_dir() { - let temp_dir = TempDir::new().unwrap(); - let nonexistent_dir = temp_dir.path().join("nonexistent"); - - let args = PackageArgs { - detect_changes: false, - git_diff: None, - skills: None, - bump: None, - auto_bump: false, - output: temp_dir.path().join("artifacts"), - force: false, - dry_run: true, - skills_dir: nonexistent_dir, - recursive: false, - }; - - let result = execute_package(args).await; - // Should handle nonexistent directory gracefully - assert!(result.is_ok() || result.is_err()); - } - - #[tokio::test] - async fn test_execute_package_empty_skills_dir() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - let args = PackageArgs { - detect_changes: false, - git_diff: None, - skills: None, - bump: None, - auto_bump: false, - output: temp_dir.path().join("artifacts"), - force: false, - dry_run: true, - skills_dir, - recursive: false, - }; - - // Should succeed with empty directory (no skills to package) - let result = execute_package(args).await; - assert!(result.is_ok() || result.is_err()); - } - - #[tokio::test] - async fn test_execute_package_dry_run() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - let args = PackageArgs { - detect_changes: false, - git_diff: None, - skills: Some(vec!["test-skill".to_string()]), - bump: None, - auto_bump: false, - output: temp_dir.path().join("artifacts"), - force: false, - dry_run: true, - skills_dir, - recursive: false, - }; - - // Dry run should not fail even if skills don't exist - let result = execute_package(args).await; - assert!(result.is_ok() || result.is_err()); - } - - #[test] - fn test_get_all_skills_shallow() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - // Create top-level skills - let skill1_dir = skills_dir.join("skill-a"); - fs::create_dir_all(&skill1_dir).unwrap(); - fs::write(skill1_dir.join("SKILL.md"), "# Skill A").unwrap(); - - let skill2_dir = skills_dir.join("skill-b"); - fs::create_dir_all(&skill2_dir).unwrap(); - fs::write(skill2_dir.join("SKILL.md"), "# Skill B").unwrap(); - - // Create nested skill (should not be found in shallow mode) - let nested_dir = skills_dir.join("category1").join("skill-c"); - fs::create_dir_all(&nested_dir).unwrap(); - fs::write(nested_dir.join("SKILL.md"), "# Skill C").unwrap(); - - let skills = get_all_skills_shallow(&skills_dir).unwrap(); - assert_eq!(skills.len(), 2); - assert!(skills.contains(&"skill-a".to_string())); - assert!(skills.contains(&"skill-b".to_string())); - assert!(!skills.contains(&"category1/skill-c".to_string())); - } - - #[test] - fn test_get_all_skills_recursive() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - // Create top-level skills - let skill1_dir = skills_dir.join("skill-a"); - fs::create_dir_all(&skill1_dir).unwrap(); - fs::write(skill1_dir.join("SKILL.md"), "# Skill A").unwrap(); - - // Create nested skills - let nested1_dir = skills_dir.join("category1").join("skill-b"); - fs::create_dir_all(&nested1_dir).unwrap(); - fs::write(nested1_dir.join("SKILL.md"), "# Skill B").unwrap(); - - let nested2_dir = skills_dir.join("category2").join("sub").join("skill-c"); - fs::create_dir_all(&nested2_dir).unwrap(); - fs::write(nested2_dir.join("SKILL.md"), "# Skill C").unwrap(); - - // Create a directory without SKILL.md (should be ignored) - let no_skill_dir = skills_dir.join("not-a-skill"); - fs::create_dir_all(&no_skill_dir).unwrap(); - - // Create hidden directory (should be ignored) - let hidden_dir = skills_dir.join(".hidden").join("skill-d"); - fs::create_dir_all(&hidden_dir).unwrap(); - fs::write(hidden_dir.join("SKILL.md"), "# Skill D").unwrap(); - - let skills = get_all_skills_recursive(&skills_dir).unwrap(); - assert_eq!(skills.len(), 3); - assert!(skills.contains(&"skill-a".to_string())); - assert!(skills.contains(&"category1/skill-b".to_string())); - assert!(skills.contains(&"category2/sub/skill-c".to_string())); - // Hidden directory should be skipped - assert!(!skills.contains(&".hidden/skill-d".to_string())); - } - - #[test] - fn test_get_all_skills_recursive_empty_dir() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - let skills = get_all_skills_recursive(&skills_dir).unwrap(); - assert_eq!(skills.len(), 0); - } - - #[test] - fn test_get_all_skills_recursive_nonexistent_dir() { - let temp_dir = TempDir::new().unwrap(); - let nonexistent_dir = temp_dir.path().join("nonexistent"); - - let skills = get_all_skills_recursive(&nonexistent_dir).unwrap(); - assert_eq!(skills.len(), 0); - } -} diff --git a/crates/fastskill-cli/src/commands/publish.rs b/crates/fastskill-cli/src/commands/publish.rs deleted file mode 100644 index 247cb559..00000000 --- a/crates/fastskill-cli/src/commands/publish.rs +++ /dev/null @@ -1,837 +0,0 @@ -//! Publish command implementation - -use crate::error::{CliError, CliResult}; -use crate::utils::{api_client::ApiClient, messages}; -use cli_framework::command::{FromArgValueMap, IntoCommandSpec}; -use cli_framework::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality}; -use cli_framework::spec::command_tree::CommandSpec; -use cli_framework::spec::value::ArgValue; -use fastskill_core::core::repository::{ - RepositoryConfig, RepositoryDefinition, RepositoryManager, RepositoryType, -}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use tracing::info; -use url::Url; - -/// Publish artifacts to remote API or local folder -/// -/// MODES (mutually exclusive): -/// --api-url Publish to API endpoint (e.g., https://api.example.com) -/// --local-dir Publish to local directory -/// --registry Use configured repository -/// (default) Use FASTSKILL_API_URL environment variable -#[derive(Debug)] -pub struct PublishArgs { - /// Package file or directory containing ZIP artifacts - pub artifacts: PathBuf, - - /// Repository name from configuration (mutually exclusive with --api-url and --local-dir) - pub registry: Option, - - /// API URL for publishing (e.g., https://api.example.com) - /// Mutually exclusive with --local-dir and --registry - pub api_url: Option, - - /// Local directory for publishing - /// Mutually exclusive with --api-url and --registry - pub local_dir: Option, - - /// Wait for validation to complete (default: true for API mode) - /// For local mode, this flag has no effect - pub wait: bool, - - /// Maximum wait time in seconds (default: 300) - pub max_wait: u64, -} - -impl IntoCommandSpec for PublishArgs { - fn command_spec() -> CommandSpec { - CommandSpec { - summary: "Publish artifacts to remote API or local folder", - syntax: Some("publish [OPTIONS]"), - category: Some("registry"), - args: vec![ - ArgSpec { - name: "artifacts", - kind: ArgKind::Option, - long: Some("artifacts"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Package file or directory containing ZIP artifacts", - default: Some(ArgValue::Str("./artifacts".to_string())), - ..Default::default() - }, - ArgSpec { - name: "registry", - kind: ArgKind::Option, - long: Some("registry"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Repository name from configuration", - ..Default::default() - }, - ArgSpec { - name: "api-url", - kind: ArgKind::Option, - long: Some("api-url"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "API URL for publishing (e.g., https://api.example.com)", - ..Default::default() - }, - ArgSpec { - name: "local-dir", - kind: ArgKind::Option, - long: Some("local-dir"), - value_type: ArgValueType::String, - cardinality: Cardinality::Optional, - help: "Local directory for publishing", - ..Default::default() - }, - ArgSpec { - name: "wait", - kind: ArgKind::Option, - long: Some("wait"), - value_type: ArgValueType::Bool, - cardinality: Cardinality::Optional, - help: "Wait for validation to complete (default: true for API mode)", - default: Some(ArgValue::Bool(true)), - ..Default::default() - }, - ArgSpec { - name: "max-wait", - kind: ArgKind::Option, - long: Some("max-wait"), - value_type: ArgValueType::Int, - cardinality: Cardinality::Optional, - help: "Maximum wait time in seconds (default: 300)", - default: Some(ArgValue::Int(300)), - ..Default::default() - }, - ], - ..Default::default() - } - } -} - -#[allow(clippy::panic)] -impl FromArgValueMap for PublishArgs { - fn from_arg_value_map(map: &HashMap) -> Self { - Self { - artifacts: map - .get("artifacts") - .and_then(|v| { - if let ArgValue::Str(s) = v { - Some(PathBuf::from(s)) - } else { - None - } - }) - .unwrap_or_else(|| PathBuf::from("./artifacts")), - registry: map.get("registry").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - api_url: map.get("api-url").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - local_dir: map.get("local-dir").and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.clone()) - } else { - None - } - }), - wait: map - .get("wait") - .and_then(|v| { - if let ArgValue::Bool(b) = v { - Some(*b) - } else { - None - } - }) - .unwrap_or(true), - max_wait: map - .get("max-wait") - .and_then(|v| { - if let ArgValue::Int(n) = v { - Some(*n as u64) - } else { - None - } - }) - .unwrap_or(300), - } - } -} - -/// Context for publish operations -#[derive(Debug)] -struct PublishContext { - target: String, - packages: Vec, - is_url: bool, - wait: bool, - max_wait: u64, -} - -impl PublishContext { - fn new(args: PublishArgs) -> CliResult { - // Validate mutually exclusive target flags (PUB_001) - validate_publish_target_exclusive(&args)?; - - // Determine target URL or path - let target = determine_target(&args)?; - let is_url = Url::parse(&target).is_ok(); - - // Collect packages - let packages = collect_packages(&args.artifacts)?; - validate_packages(&packages, &args.artifacts)?; - - // Wait defaults to false (user must explicitly enable it) - let wait = args.wait; - - Ok(Self { - target, - packages, - is_url, - wait, - max_wait: args.max_wait, - }) - } -} - -/// Validate that only one target mode is specified (PUB_001) -fn validate_publish_target_exclusive(args: &PublishArgs) -> CliResult<()> { - let target_count = [&args.registry, &args.api_url, &args.local_dir] - .iter() - .filter(|t| t.is_some()) - .count(); - - if target_count > 1 { - return Err(CliError::Validation( - "PUB_001: Cannot specify multiple target modes. Use only one of: --registry, --api-url, --local-dir".to_string(), - )); - } - Ok(()) -} - -/// Determine the target URL or path from arguments -fn determine_target(args: &PublishArgs) -> CliResult { - if let Some(registry_name) = &args.registry { - determine_target_from_registry(registry_name) - } else if let Some(api_url) = &args.api_url { - Ok(api_url.clone()) - } else if let Some(local_dir) = &args.local_dir { - Ok(local_dir.clone()) - } else { - // Default: Use FASTSKILL_API_URL env var - std::env::var("FASTSKILL_API_URL").map_err(|_| { - CliError::Validation( - "PUB_002: No target specified and FASTSKILL_API_URL not set. Use --api-url, --local-dir, --registry, or set FASTSKILL_API_URL".to_string(), - ) - }) - } -} - -/// Determine target from registry configuration -fn determine_target_from_registry(registry_name: &str) -> CliResult { - let repositories = crate::config::load_repositories_from_project()?; - let repo_manager = RepositoryManager::from_definitions(repositories); - - let repo = repo_manager.get_repository(registry_name).ok_or_else(|| { - CliError::Config(format!( - "Repository '{}' not found in repositories.toml", - registry_name - )) - })?; - - extract_api_url_from_repository(repo) -} - -/// Validate that packages were found -fn validate_packages(packages: &[PathBuf], artifacts: &Path) -> CliResult<()> { - if packages.is_empty() { - return Err(CliError::Validation(format!( - "No ZIP packages found in: {}", - artifacts.display() - ))); - } - Ok(()) -} - -pub async fn execute_publish(args: PublishArgs) -> CliResult<()> { - info!("Starting publish command"); - - // Create publish context - let context = PublishContext::new(args)?; - - println!( - "{}", - messages::info(&format!( - "Found {} package(s) to publish", - context.packages.len() - )) - ); - - // Execute publish based on target type - if context.is_url { - publish_to_api_with_auth(&context).await?; - } else { - publish_to_local_folder(&context.target, &context.packages).await?; - } - - Ok(()) -} - -/// Publish to API with authentication -async fn publish_to_api_with_auth(context: &PublishContext) -> CliResult<()> { - let token = crate::auth_config::get_token_with_refresh(&context.target).await?; - let token_str = token.ok_or_else(|| { - CliError::Validation(format!( - "No authentication token found for registry: {}. Run `fastskill auth login` to authenticate.", - context.target - )) - })?; - - publish_to_api( - &context.target, - &context.packages, - Some(&token_str), - context.wait, - context.max_wait, - ) - .await -} - -/// Collect packages from artifacts path (file or directory) -fn collect_packages(artifacts: &PathBuf) -> CliResult> { - if !artifacts.exists() { - return Err(CliError::Validation(format!( - "Artifacts path does not exist: {}", - artifacts.display() - ))); - } - - if artifacts.is_file() { - // Single file - if artifacts.extension().and_then(|s| s.to_str()) == Some("zip") { - Ok(vec![artifacts.clone()]) - } else { - Err(CliError::Validation(format!( - "Artifact must be a ZIP file: {}", - artifacts.display() - ))) - } - } else { - // Directory - find all ZIP files - find_zip_files(artifacts) - } -} - -/// Check if a path is a ZIP file -fn is_zip_file(path: &Path) -> bool { - path.is_file() && path.extension().is_some_and(|ext| ext == "zip") -} - -/// Find all ZIP files in a directory -fn find_zip_files(dir: &PathBuf) -> CliResult> { - let entries = std::fs::read_dir(dir).map_err(CliError::Io)?; - - let zip_files: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| is_zip_file(path)) - .collect(); - - Ok(zip_files) -} - -/// Get package name from path -fn get_package_name(package: &Path) -> &str { - package - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") -} - -/// Handle validation status after waiting -fn handle_validation_status( - status: &crate::utils::api_client::PublishStatusApiResponse, -) -> CliResult<()> { - match status.status.as_str() { - "pending" | "validating" => { - eprintln!( - "{}", - messages::error(&format!( - "Timeout waiting for validation. Current status: {}. Use --max-wait to increase timeout.", - status.status - )) - ); - Ok(()) - } - "accepted" => { - println!( - "{}", - messages::ok(&format!( - "Package accepted: {} v{}", - status.skill_id, status.version - )) - ); - Ok(()) - } - "rejected" => { - eprintln!( - "{}", - messages::error(&format!( - "Package validation failed: {:?}", - status.validation_errors - )) - ); - Err(CliError::Validation(format!( - "Package was rejected: {:?}", - status.validation_errors - ))) - } - _ => Ok(()), - } -} - -/// Wait for package validation to complete -async fn wait_for_package_validation( - client: &ApiClient, - job_id: &str, - max_wait: u64, -) -> CliResult<()> { - println!("{}", messages::info("Waiting for validation...")); - - match client.wait_for_completion(job_id, max_wait).await { - Ok(status) => handle_validation_status(&status), - Err(e) => { - eprintln!( - "{}", - messages::error(&format!("Package validation failed: {}", e)) - ); - Err(e) - } - } -} - -/// Publish a single package to API -async fn publish_single_package( - client: &ApiClient, - package: &Path, - api_url: &str, - wait: bool, - max_wait: u64, -) -> CliResult<()> { - let package_name = get_package_name(package); - - println!( - "{}", - messages::info(&format!("Publishing: {}", package_name)) - ); - - // Publish package - let response = client - .publish_package(package) - .await - .map_err(|e| CliError::Validation(format!("Failed to publish {}: {}", package_name, e)))?; - - println!( - "{}", - messages::ok(&format!( - "Package queued: {} v{} (job: {})", - response.skill_id, response.version, response.job_id - )) - ); - - // Get initial status - let initial_status = client - .get_publish_status(&response.job_id) - .await - .map_err(|e| CliError::Validation(format!("Failed to get initial status: {}", e)))?; - - println!( - "{}", - messages::info(&format!("Initial status: {}", initial_status.status)) - ); - - // Wait for completion if requested - if wait { - wait_for_package_validation(client, &response.job_id, max_wait).await?; - } else { - println!( - "{}", - messages::info(&format!( - "Check status with: GET {}/api/v1/registry/publish/status/{}", - api_url, response.job_id - )) - ); - } - - Ok(()) -} - -/// Publish packages to API -async fn publish_to_api( - api_url: &str, - packages: &[PathBuf], - token: Option<&str>, - wait: bool, - max_wait: u64, -) -> CliResult<()> { - let client = ApiClient::new(api_url, token.map(|s| s.to_string()))?; - - println!( - "{}", - messages::info(&format!("Publishing to API: {}", api_url)) - ); - - for package in packages { - publish_single_package(&client, package, api_url, wait, max_wait).await?; - } - - Ok(()) -} - -/// Publish packages to local folder -async fn publish_to_local_folder(target: &str, packages: &[PathBuf]) -> CliResult<()> { - let target_path = PathBuf::from(target); - - // Create target directory if it doesn't exist - if !target_path.exists() { - std::fs::create_dir_all(&target_path).map_err(CliError::Io)?; - println!( - "{}", - messages::info(&format!("Created target directory: {}", target)) - ); - } - - if !target_path.is_dir() { - return Err(CliError::Validation(format!( - "Target must be a directory: {}", - target - ))); - } - - println!( - "{}", - messages::info(&format!("Publishing to local folder: {}", target)) - ); - - for package in packages { - let package_name = package - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - let dest_path = target_path.join(package_name); - - println!( - "{}", - messages::info(&format!( - "Copying: {} -> {}", - package_name, - dest_path.display() - )) - ); - - // Copy file - std::fs::copy(package, &dest_path).map_err(CliError::Io)?; - - println!("{}", messages::ok(&format!("Published: {}", package_name))); - } - - println!(); - println!( - "{}", - messages::ok(&format!( - "Published {} package(s) to local folder", - packages.len() - )) - ); - - Ok(()) -} - -/// Extract API URL from repository configuration -fn extract_api_url_from_repository(repo: &RepositoryDefinition) -> CliResult { - match &repo.repo_type { - RepositoryType::HttpRegistry => { - // For http-registry, check storage.base_url first - if let Some(storage) = &repo.storage { - if let Some(base_url) = &storage.base_url { - return Ok(base_url.clone()); - } - } - - // If index_url is already an API URL (not a git URL), use it - let index_url = match &repo.config { - RepositoryConfig::HttpRegistry { index_url } => index_url, - _ => { - return Err(CliError::Config( - "Invalid repository config".to_string(), - )) - } - }; - - // If it's already an HTTP(S) URL, use it - if index_url.starts_with("http://") || index_url.starts_with("https://") { - // It's an API URL, extract base URL (remove /index suffix if present) - let base = index_url.trim_end_matches("/index").trim_end_matches("/"); - return Ok(base.to_string()); - } - - // For non-HTTP URLs, we can't determine the API URL - Err(CliError::Config(format!( - "Cannot determine API URL for http-registry '{}'. \ - Please specify storage.base_url in repositories.toml or use --api-url.", - repo.name - ))) - } - _ => Err(CliError::Config(format!( - "Repository type '{}' does not support publishing. Only http-registry type supports publishing.", - repo.name - ))), - } -} - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::panic, - clippy::expect_used, - clippy::ptr_arg -)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - /// Create test publish args - fn create_test_args(artifacts: PathBuf, local_dir: Option, wait: bool) -> PublishArgs { - PublishArgs { - artifacts, - registry: None, - api_url: None, - local_dir, - wait, - max_wait: 300, - } - } - - /// Create a valid ZIP file for testing - fn create_test_zip(artifacts_dir: &PathBuf, skill_name: &str) { - use std::io::Write; - use zip::{write::FileOptions, ZipWriter}; - - let skill_content = format!( - r#"# {} - -Name: {} -Version: 1.0.0 -Description: A test skill for coverage -"#, - skill_name, skill_name - ); - - let zip_path = artifacts_dir.join(format!("{}-1.0.0.zip", skill_name)); - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip = ZipWriter::new(file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - zip.start_file("SKILL.md", options).unwrap(); - zip.write_all(skill_content.as_bytes()).unwrap(); - zip.finish().unwrap(); - } - - #[tokio::test] - async fn test_execute_publish_nonexistent_artifacts() { - let temp_dir = TempDir::new().unwrap(); - let nonexistent_path = temp_dir.path().join("nonexistent"); - - // Provide a target to avoid PUB_002 error - let args = create_test_args( - nonexistent_path, - Some("/tmp/test-publish".to_string()), - true, - ); - - let result = execute_publish(args).await; - assert!(result.is_err()); - if let Err(CliError::Validation(msg)) = result { - assert!(msg.contains("does not exist")); - } else { - panic!("Expected Validation error"); - } - } - - #[tokio::test] - async fn test_execute_publish_empty_artifacts_dir() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - let args = create_test_args(artifacts_dir, Some("/tmp/test-publish".to_string()), true); - - let result = execute_publish(args).await; - assert!(result.is_err()); - if let Err(CliError::Validation(msg)) = result { - assert!(msg.contains("No ZIP packages found")); - } else { - panic!("Expected Validation error"); - } - } - - #[tokio::test] - async fn test_execute_publish_invalid_file_type() { - let temp_dir = TempDir::new().unwrap(); - let invalid_file = temp_dir.path().join("not-a-zip.txt"); - fs::write(&invalid_file, "not a zip file").unwrap(); - - let args = create_test_args(invalid_file, Some("/tmp/test-publish".to_string()), true); - - let result = execute_publish(args).await; - assert!(result.is_err()); - if let Err(CliError::Validation(msg)) = result { - assert!(msg.contains("ZIP file")); - } else { - panic!("Expected Validation error"); - } - } - - #[tokio::test] - async fn test_execute_publish_success() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - let target_dir = temp_dir.path().join("target"); - fs::create_dir_all(&target_dir).unwrap(); - - create_test_zip(&artifacts_dir, "test-skill"); - - let args = create_test_args(artifacts_dir, Some(target_dir.display().to_string()), false); - - let result = execute_publish(args).await; - // May fail due to missing S3 config or other issues, but should process the zip - assert!(result.is_ok() || result.is_err()); - } - - #[test] - fn test_publish_context_api_url_target() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - create_test_zip(&artifacts_dir, "test-skill"); - - let args = PublishArgs { - artifacts: artifacts_dir, - registry: None, - api_url: Some("https://registry.example.com".to_string()), - local_dir: None, - wait: false, - max_wait: 300, - }; - - // This test verifies the context creation succeeds with an API URL target - let result = PublishContext::new(args); - assert!(result.is_ok()); - } - - #[test] - fn test_publish_context_local_path() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - let target_dir = temp_dir.path().join("target"); - fs::create_dir_all(&target_dir).unwrap(); - - create_test_zip(&artifacts_dir, "test-skill"); - - let args = PublishArgs { - artifacts: artifacts_dir, - registry: None, - api_url: None, - local_dir: Some(target_dir.display().to_string()), - wait: false, - max_wait: 300, - }; - - // This test verifies the context creation succeeds with a local path - let result = PublishContext::new(args); - assert!(result.is_ok()); - } - - #[test] - fn test_publish_context_conflicting_targets_pub_001() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - create_test_zip(&artifacts_dir, "test-skill"); - - // Test: both api_url and local_dir specified (should fail) - let args = PublishArgs { - artifacts: artifacts_dir.clone(), - registry: None, - api_url: Some("https://registry.example.com".to_string()), - local_dir: Some("/tmp/target".to_string()), - wait: false, - max_wait: 300, - }; - - let result = PublishContext::new(args); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, CliError::Validation(msg) if msg.contains("PUB_001"))); - - // Test: both registry and api_url specified (should fail) - let args = PublishArgs { - artifacts: artifacts_dir.clone(), - registry: Some("my-registry".to_string()), - api_url: Some("https://registry.example.com".to_string()), - local_dir: None, - wait: false, - max_wait: 300, - }; - - let result = PublishContext::new(args); - assert!(result.is_err()); - } - - #[test] - fn test_publish_context_no_target_pub_002() { - let temp_dir = TempDir::new().unwrap(); - let artifacts_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&artifacts_dir).unwrap(); - - create_test_zip(&artifacts_dir, "test-skill"); - - // Test: no target specified and no env var (should fail) - let args = PublishArgs { - artifacts: artifacts_dir, - registry: None, - api_url: None, - local_dir: None, - wait: false, - max_wait: 300, - }; - - let result = PublishContext::new(args); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, CliError::Validation(msg) if msg.contains("PUB_002"))); - } -} diff --git a/crates/fastskill-cli/src/config.rs b/crates/fastskill-cli/src/config.rs index 38a24833..b7cd61bc 100644 --- a/crates/fastskill-cli/src/config.rs +++ b/crates/fastskill-cli/src/config.rs @@ -5,7 +5,7 @@ use fastskill_core::core::manifest::SkillProjectToml; use fastskill_core::core::project; use fastskill_core::core::repository::RepositoryDefinition; use fastskill_core::core::service::HttpServerConfig; -use fastskill_core::{core::BlobStorageConfig, ServiceConfig}; +use fastskill_core::ServiceConfig; use std::env; use std::path::PathBuf; use tracing::debug; @@ -202,45 +202,14 @@ pub fn create_service_config( // Load server configuration from skill-project.toml let http_server_config = load_server_config()?; - // Read registry configuration from environment variables - let registry_blob_storage = - if let (Ok(bucket), Ok(region)) = (env::var("S3_BUCKET"), env::var("S3_REGION")) { - let access_key = env::var("AWS_ACCESS_KEY_ID").unwrap_or_default(); - let secret_key = env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_default(); - let endpoint = env::var("S3_ENDPOINT").ok(); - let base_url = env::var("BLOB_BASE_URL").ok(); - - if !bucket.is_empty() && !region.is_empty() { - Some(BlobStorageConfig { - storage_type: "s3".to_string(), - base_path: String::new(), - bucket, - region, - endpoint, - access_key, - secret_key, - base_url, - }) - } else { - None - } - } else { - None - }; - // Read registry index path from environment let registry_index_path = env::var("REGISTRY_INDEX_PATH").ok().map(PathBuf::from); - // Read staging directory from environment or use default - let staging_dir = env::var("REGISTRY_STAGING_DIR").ok().map(PathBuf::from); - Ok(ServiceConfig { skill_storage_path: resolved_dir, embedding: embedding_config, http_server: http_server_config, - registry_blob_storage, registry_index_path, - staging_dir, ..Default::default() }) } diff --git a/crates/fastskill-cli/src/main.rs b/crates/fastskill-cli/src/main.rs index bb0f0b58..b6ef3915 100644 --- a/crates/fastskill-cli/src/main.rs +++ b/crates/fastskill-cli/src/main.rs @@ -11,7 +11,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod arg_helpers; -mod auth_config; mod commands; mod config; mod config_file; @@ -61,11 +60,9 @@ fn ctx_skills_dir(ctx: &dyn AppContext) -> Option { }) } -#[cfg(feature = "registry-publish")] -use commands::publish; use commands::{ - add, analyze, auth, doctor, eval, init, install, list, marketplace, package, read, reindex, - remove, repos, search, serve, skillopt, update, + add, analyze, doctor, eval, init, install, list, marketplace, read, reindex, remove, repos, + search, serve, skillopt, update, }; #[tokio::main] @@ -97,7 +94,7 @@ async fn main() { let raw = { // The set of recognized first path segments: every registered command // (including built-ins like `spec`/`completion`/`mcp`) and every group - // node (`analyze`, `auth`, `repos`, ...). `help` is clap-provided. + // node (`analyze`, `repos`, ...). `help` is clap-provided. let registry = app.command_registry(); let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new(); known.insert("help"); @@ -202,25 +199,7 @@ fn build_app(builder: AppBuilder, state: Arc) -> anyhow::Result) -> anyhow::Result, - client: Client, -} - -/// Publish response from API -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct PublishApiResponse { - pub job_id: String, - #[allow(dead_code)] - pub status: String, - pub skill_id: String, - pub version: String, - #[allow(dead_code)] - pub message: String, -} - -/// Publish status response from API -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct PublishStatusApiResponse { - #[allow(dead_code)] - pub job_id: String, - pub status: String, - pub skill_id: String, - pub version: String, - #[allow(dead_code)] - pub checksum: String, - #[allow(dead_code)] - pub uploaded_at: String, - #[allow(dead_code)] - pub uploaded_by: Option, - pub validation_errors: Vec, - #[allow(dead_code)] - pub message: Option, -} - -/// Generic API response wrapper -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiResponse { - pub success: bool, - pub data: Option, - pub error: Option, -} - -impl ApiClient { - /// Create a new API client - pub fn new(base_url: &str, token: Option) -> CliResult { - let client = Client::builder() - .timeout(Duration::from_secs(300)) // 5 minute timeout for large uploads - .build() - .map_err(|e| CliError::Validation(format!("Failed to create HTTP client: {}", e)))?; - - Ok(Self { - base_url: base_url.trim_end_matches('/').to_string(), - token, - client, - }) - } - - /// Publish a package to the registry API - /// The server will extract the scope from the JWT token's user account - pub async fn publish_package(&self, package_path: &Path) -> CliResult { - let url = format!("{}/api/v1/registry/publish", self.base_url); - - // Read package file - let package_data = std::fs::read(package_path).map_err(CliError::Io)?; - - // Get filename - let filename = package_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| CliError::Validation("Invalid package filename".to_string()))?; - - // Create multipart form (server will extract scope from JWT token) - let form = reqwest::multipart::Form::new().part( - "file", - reqwest::multipart::Part::bytes(package_data).file_name(filename.to_string()), - ); - - // Build request - let mut request = self.client.post(&url).multipart(form); - - // Add authentication - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } - - // Send request - let response = request - .send() - .await - .map_err(|e| CliError::Validation(format!("Failed to send request: {}", e)))?; - - // Check status - let status = response.status(); - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CliError::Validation(format!( - "Publish failed with status {}: {}", - status, error_text - ))); - } - - // Parse response - let api_response: ApiResponse = response - .json() - .await - .map_err(|e| CliError::Validation(format!("Failed to parse response: {}", e)))?; - - if !api_response.success { - return Err(CliError::Validation(format!( - "Publish failed: {:?}", - api_response.error - ))); - } - - api_response - .data - .ok_or_else(|| CliError::Validation("No data in response".to_string())) - } - - /// Get publish job status - pub async fn get_publish_status(&self, job_id: &str) -> CliResult { - let url = format!( - "{}/api/v1/registry/publish/status/{}", - self.base_url, job_id - ); - - // Build request - let mut request = self.client.get(&url); - - // Add authentication - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } - - // Send request - let response = request - .send() - .await - .map_err(|e| CliError::Validation(format!("Failed to send request: {}", e)))?; - - // Check status - let status = response.status(); - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(CliError::Validation(format!( - "Status check failed with status {}: {}", - status, error_text - ))); - } - - // Parse response - let api_response: ApiResponse = response - .json() - .await - .map_err(|e| CliError::Validation(format!("Failed to parse response: {}", e)))?; - - if !api_response.success { - return Err(CliError::Validation(format!( - "Status check failed: {:?}", - api_response.error - ))); - } - - api_response - .data - .ok_or_else(|| CliError::Validation("No data in response".to_string())) - } - - /// Poll for publish status until accepted or rejected - pub async fn wait_for_completion( - &self, - job_id: &str, - max_wait_seconds: u64, - ) -> CliResult { - use std::time::{Duration, Instant}; - use tokio::time::sleep; - - let start = Instant::now(); - let poll_interval = Duration::from_secs(2); - - loop { - let status = self.get_publish_status(job_id).await?; - - match status.status.as_str() { - "accepted" => return Ok(status), - "rejected" => { - return Err(CliError::Validation(format!( - "Package was rejected: {:?}", - status.validation_errors - ))); - } - _ => { - // Still pending or validating - if start.elapsed().as_secs() > max_wait_seconds { - // Return current status instead of error, but this indicates timeout - // The caller should handle this appropriately - return Ok(status); - } - sleep(poll_interval).await; - } - } - } - } -} diff --git a/crates/fastskill-core/Cargo.toml b/crates/fastskill-core/Cargo.toml index a1a4786c..0956884f 100644 --- a/crates/fastskill-core/Cargo.toml +++ b/crates/fastskill-core/Cargo.toml @@ -48,9 +48,6 @@ zip.workspace = true # HTTP client for embedding API (rustls for musl compatibility) reqwest.workspace = true -# Multipart form data handling for skill uploads -multer.workspace = true - # HTTP server axum.workspace = true tower.workspace = true @@ -66,9 +63,6 @@ rusqlite.workspace = true # SHA256 hashing for file integrity sha2.workspace = true -# Base64 encoding/decoding for JWT parsing -base64.workspace = true - # Logging tracing.workspace = true tracing-subscriber.workspace = true @@ -91,10 +85,6 @@ include_dir.workspace = true # Semantic versioning semver.workspace = true -# AWS S3 storage (optional - only for registry publishing) -aws-sdk-s3 = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } - [dev-dependencies] tempfile.workspace = true wiremock = "0.5" @@ -109,8 +99,5 @@ default = ["filesystem-storage"] filesystem-storage = [] hot-reload = ["notify"] -# Registry publishing (requires AWS SDK) -registry-publish = ["aws-sdk-s3", "aws-config"] - [lints] workspace = true diff --git a/crates/fastskill-core/README.md b/crates/fastskill-core/README.md index 0625f176..cd290427 100644 --- a/crates/fastskill-core/README.md +++ b/crates/fastskill-core/README.md @@ -101,7 +101,6 @@ pub async fn retrieve_skills_for_turn( ## Feature flags - `filesystem-storage` (default): local storage backend. -- `registry-publish`: opt-in registry publishing support. - `hot-reload`: filesystem watch support for skill changes. ## Core capabilities diff --git a/crates/fastskill-core/src/core/blob_storage.rs b/crates/fastskill-core/src/core/blob_storage.rs deleted file mode 100644 index 82d018c5..00000000 --- a/crates/fastskill-core/src/core/blob_storage.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Blob storage abstraction for artifact publishing - -use crate::core::service::ServiceError; -use async_trait::async_trait; -#[allow(unused_imports)] // StreamExt is used in download() method -use futures::StreamExt; -use std::sync::Arc; -#[cfg(feature = "registry-publish")] -use tracing::info; - -/// Trait for blob storage backends -#[async_trait] -pub trait BlobStorage: Send + Sync { - /// Upload data to blob storage - async fn upload(&self, path: &str, data: &[u8]) -> Result; - - /// Download data from blob storage - async fn download(&self, path: &str) -> Result, ServiceError>; - - /// Check if a path exists in blob storage - async fn exists(&self, path: &str) -> Result; - - /// Delete a path from blob storage - async fn delete(&self, path: &str) -> Result<(), ServiceError>; - - /// Get the base URL for downloads - fn base_url(&self) -> Option<&str>; -} - -/// Local filesystem blob storage (for testing) -pub struct LocalBlobStorage { - base_path: std::path::PathBuf, - base_url: Option, -} - -impl LocalBlobStorage { - pub fn new(base_path: std::path::PathBuf, base_url: Option) -> Self { - Self { - base_path, - base_url, - } - } -} - -#[async_trait] -impl BlobStorage for LocalBlobStorage { - async fn upload(&self, path: &str, data: &[u8]) -> Result { - let full_path = self.base_path.join(path); - - // Create parent directory if needed - if let Some(parent) = full_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(ServiceError::Io)?; - } - - tokio::fs::write(&full_path, data) - .await - .map_err(ServiceError::Io)?; - - Ok(path.to_string()) - } - - async fn download(&self, path: &str) -> Result, ServiceError> { - let full_path = self.base_path.join(path); - let data = tokio::fs::read(&full_path) - .await - .map_err(ServiceError::Io)?; - Ok(data) - } - - async fn exists(&self, path: &str) -> Result { - let full_path = self.base_path.join(path); - Ok(full_path.exists()) - } - - async fn delete(&self, path: &str) -> Result<(), ServiceError> { - let full_path = self.base_path.join(path); - if full_path.exists() { - if full_path.is_dir() { - tokio::fs::remove_dir_all(&full_path) - .await - .map_err(ServiceError::Io)?; - } else { - tokio::fs::remove_file(&full_path) - .await - .map_err(ServiceError::Io)?; - } - } - Ok(()) - } - - fn base_url(&self) -> Option<&str> { - self.base_url.as_deref() - } -} - -/// S3 blob storage (supports AWS S3 and S3-compatible services via endpoint) -#[cfg(feature = "registry-publish")] -pub struct S3BlobStorage { - client: aws_sdk_s3::Client, - bucket: String, - base_url: Option, -} - -#[cfg(feature = "registry-publish")] -impl S3BlobStorage { - pub async fn new( - bucket: String, - region: String, - endpoint: Option, - access_key: String, - secret_key: String, - base_url: Option, - ) -> Result { - use aws_config::meta::region::RegionProviderChain; - use aws_config::Region; - use aws_sdk_s3::config::Credentials; - - // Build AWS config - let mut config_builder = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .region(RegionProviderChain::first_try(if region.is_empty() { - Region::new("us-east-1") - } else { - Region::new(region.clone()) - })); - - // Set custom endpoint if provided (for S3-compatible services) - if let Some(endpoint_url) = endpoint { - config_builder = config_builder.endpoint_url(endpoint_url); - } - - // Handle credentials: use config if provided, otherwise let AWS SDK use default chain - if !access_key.is_empty() && !secret_key.is_empty() { - let credentials = Credentials::new(access_key, secret_key, None, None, "fastskill"); - config_builder = config_builder.credentials_provider(credentials); - } - - let config = config_builder.load().await; - let client = aws_sdk_s3::Client::new(&config); - - Ok(Self { - client, - bucket, - base_url, - }) - } -} - -#[cfg(feature = "registry-publish")] -#[async_trait] -impl BlobStorage for S3BlobStorage { - async fn upload(&self, path: &str, data: &[u8]) -> Result { - use aws_sdk_s3::primitives::ByteStream; - - let body = ByteStream::from(data.to_vec()); - - let request = self - .client - .put_object() - .bucket(&self.bucket) - .key(path) - .body(body) - .content_type("application/zip"); - - let _result = request.send().await.map_err(|e| { - ServiceError::Custom(format!( - "Failed to upload to S3: {}", - map_s3_error(&e as &dyn std::error::Error) - )) - })?; - - // Return the path that was uploaded - Ok(path.to_string()) - } - - async fn download(&self, path: &str) -> Result, ServiceError> { - let result = self - .client - .get_object() - .bucket(&self.bucket) - .key(path) - .send() - .await - .map_err(|e| { - let error_msg = map_s3_error(&e as &dyn std::error::Error); - if error_msg.contains("NoSuchKey") || error_msg.contains("not found") { - ServiceError::Custom(format!("Object not found: {}", path)) - } else { - ServiceError::Custom(format!("Failed to download from S3: {}", error_msg)) - } - })?; - - let mut body = result.body; - let mut data = Vec::new(); - while let Some(chunk) = body.next().await { - let chunk = chunk - .map_err(|e| ServiceError::Custom(format!("Failed to read S3 response: {}", e)))?; - data.extend_from_slice(&chunk); - } - - Ok(data) - } - - async fn exists(&self, path: &str) -> Result { - let result = self - .client - .head_object() - .bucket(&self.bucket) - .key(path) - .send() - .await; - - match result { - Ok(_) => Ok(true), - Err(e) => { - let error_msg = map_s3_error(&e as &dyn std::error::Error); - if error_msg.contains("NoSuchKey") - || error_msg.contains("404") - || error_msg.contains("not found") - { - Ok(false) - } else { - Err(ServiceError::Custom(format!( - "Failed to check object existence in S3: {}", - error_msg - ))) - } - } - } - } - - async fn delete(&self, path: &str) -> Result<(), ServiceError> { - let result = self - .client - .delete_object() - .bucket(&self.bucket) - .key(path) - .send() - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => { - let error_msg = map_s3_error(&e as &dyn std::error::Error); - // Delete is idempotent - if object doesn't exist, that's OK - if error_msg.contains("NoSuchKey") || error_msg.contains("not found") { - Ok(()) - } else { - Err(ServiceError::Custom(format!( - "Failed to delete from S3: {}", - error_msg - ))) - } - } - } - } - - fn base_url(&self) -> Option<&str> { - self.base_url.as_deref() - } -} - -/// Map AWS SDK errors to user-friendly error messages -#[cfg(feature = "registry-publish")] -fn map_s3_error(err: &dyn std::error::Error) -> String { - let err_str = err.to_string(); - - // Check for common S3 error patterns - if err_str.contains("NoSuchKey") || err_str.contains("not found") { - "Object not found".to_string() - } else if err_str.contains("NoSuchBucket") { - "Bucket not found".to_string() - } else if err_str.contains("AccessDenied") || err_str.contains("access denied") { - "Access denied".to_string() - } else if err_str.contains("timeout") { - "Request timeout".to_string() - } else { - format!("S3 error: {}", err_str) - } -} - -/// Create blob storage from configuration -pub async fn create_blob_storage( - storage_type: &str, - config: &BlobStorageConfig, -) -> Result, ServiceError> { - match storage_type { - "local" => { - let base_path = std::path::PathBuf::from(&config.base_path); - Ok(Arc::new(LocalBlobStorage::new( - base_path, - config.base_url.clone(), - ))) - } - #[cfg(feature = "registry-publish")] - "s3" => { - // S3 storage supports both AWS S3 and S3-compatible services - // When endpoint is set, it points to an S3-compatible service - info!( - "Creating S3 blob storage: bucket='{}', region='{}', endpoint='{}', base_url='{}'", - config.bucket, - config.region, - config.endpoint.as_deref().unwrap_or(""), - config.base_url.as_deref().unwrap_or(""), - ); - - // Note: access_key / secret_key are intentionally NOT logged for security. - let storage = S3BlobStorage::new( - config.bucket.clone(), - config.region.clone(), - config.endpoint.clone(), - config.access_key.clone(), - config.secret_key.clone(), - config.base_url.clone(), - ) - .await?; - Ok(Arc::new(storage)) - } - #[cfg(not(feature = "registry-publish"))] - "s3" => Err(ServiceError::Custom( - "S3 storage requires the 'registry-publish' feature to be enabled".to_string(), - )), - _ => Err(ServiceError::Custom(format!( - "Unsupported storage type: {}", - storage_type - ))), - } -} - -/// Blob storage configuration -#[derive(Debug, Clone)] -pub struct BlobStorageConfig { - pub storage_type: String, - pub base_path: String, - pub bucket: String, - pub region: String, - pub endpoint: Option, - pub access_key: String, - pub secret_key: String, - pub base_url: Option, -} - -impl Default for BlobStorageConfig { - fn default() -> Self { - Self { - storage_type: "local".to_string(), - base_path: "./artifacts".to_string(), - bucket: String::new(), - region: String::new(), - endpoint: None, - access_key: String::new(), - secret_key: String::new(), - base_url: None, - } - } -} diff --git a/crates/fastskill-core/src/core/mod.rs b/crates/fastskill-core/src/core/mod.rs index f48f308c..9f54d4c7 100644 --- a/crates/fastskill-core/src/core/mod.rs +++ b/crates/fastskill-core/src/core/mod.rs @@ -1,7 +1,6 @@ //! Core service layer modules pub mod analysis; -pub mod blob_storage; pub mod build_cache; pub mod change_detection; pub mod context_resolver; @@ -12,7 +11,6 @@ pub mod frontmatter; pub mod lock; pub mod manifest; pub mod metadata; -pub mod packaging; pub mod project; pub mod project_config; pub mod reconciliation; @@ -33,7 +31,6 @@ pub mod version_bump; // Re-export main types for convenience // Note: Selective re-exports to avoid conflicts -pub use blob_storage::{create_blob_storage, BlobStorage, BlobStorageConfig, LocalBlobStorage}; pub use build_cache::{BuildCache, SkillCacheEntry}; pub use change_detection::{ calculate_skill_hash, detect_changed_skills_git, detect_changed_skills_hash, @@ -66,12 +63,6 @@ pub use metadata::{ parse_yaml_frontmatter, MetadataService, MetadataServiceImpl, SkillFrontmatter, SkillMetadata, }; -// packaging -pub use packaging::{ - calculate_checksum, create_build_metadata, package_skill, package_skill_with_id, - BuildEnvironment, BuildMetadata, -}; - // project_config pub use project_config::{load_project_config, ProjectConfig}; @@ -82,10 +73,7 @@ pub use registry::{ }; // registry_index -pub use registry_index::{ - create_registry_structure, get_skill_index_path, get_version_metadata, migrate_index_format, - read_skill_versions, IndexMetadata, VersionEntry, VersionMetadata, -}; +pub use registry_index::{read_skill_versions, IndexMetadata, VersionEntry, VersionMetadata}; // repository pub use repository::{ diff --git a/crates/fastskill-core/src/core/packaging.rs b/crates/fastskill-core/src/core/packaging.rs deleted file mode 100644 index 976f79bb..00000000 --- a/crates/fastskill-core/src/core/packaging.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Packaging skills into ZIP artifacts with metadata - -use crate::core::service::ServiceError; -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use zip::write::{FileOptions, ZipWriter}; -use zip::CompressionMethod; - -/// Package a skill directory into a ZIP file -pub fn package_skill( - skill_path: &Path, - output_dir: &Path, - version: &str, -) -> Result { - package_skill_with_id(skill_path, output_dir, version, None) -} - -/// Package a skill directory into a ZIP file with explicit skill ID. -/// -/// The `evals/` subtree is excluded from the archive (local eval suites are not published). -pub fn package_skill_with_id( - skill_path: &Path, - output_dir: &Path, - version: &str, - skill_id_override: Option<&str>, -) -> Result { - // Validate skill directory - if !skill_path.exists() { - return Err(ServiceError::Custom(format!( - "Skill directory does not exist: {}", - skill_path.display() - ))); - } - - if !skill_path.is_dir() { - return Err(ServiceError::Custom(format!( - "Path is not a directory: {}", - skill_path.display() - ))); - } - - // Check for SKILL.md - if !skill_path.join("SKILL.md").exists() { - return Err(ServiceError::Custom(format!( - "SKILL.md not found in skill directory: {}", - skill_path.display() - ))); - } - - // Validate skill against AI Skill standard - use crate::validation::standard_validator::StandardValidator; - let validation_result = StandardValidator::validate_skill_directory(skill_path)?; - if !validation_result.is_valid { - let error_messages: Vec = validation_result.errors.iter().map(|e| { - match e { - crate::validation::standard_validator::ValidationError::InvalidNameFormat(msg) => - format!("✗ Name format invalid: {}", msg), - crate::validation::standard_validator::ValidationError::NameMismatch { expected, actual } => - format!("✗ Name mismatch: Directory '{}' doesn't match skill name '{}'", actual, expected), - crate::validation::standard_validator::ValidationError::InvalidDescriptionLength(len) => - format!("✗ Description length invalid: {} characters (must be 1-1024)", len), - crate::validation::standard_validator::ValidationError::InvalidCompatibilityLength(len) => - format!("✗ Compatibility field too long: {} characters (max 500)", len), - crate::validation::standard_validator::ValidationError::MissingRequiredField(field) => - format!("✗ Missing required field: {}", field), - crate::validation::standard_validator::ValidationError::InvalidFileReference(msg) => - format!("✗ Invalid file reference: {}", msg), - crate::validation::standard_validator::ValidationError::InvalidDirectoryStructure(msg) => - format!("✗ Invalid directory structure: {}", msg), - crate::validation::standard_validator::ValidationError::YamlParseError(msg) => - format!("✗ YAML parsing error: {}", msg), - } - }).collect(); - - return Err(ServiceError::Validation(error_messages.join("\n"))); - } - - // Try to read metadata from skill-project.toml if it exists (T042, T043) - let skill_project_toml_path = skill_path.join("skill-project.toml"); - let (skill_id, package_version) = if skill_project_toml_path.exists() { - match crate::core::manifest::SkillProjectToml::load_from_file(&skill_project_toml_path) { - Ok(project) => { - // Extract skill ID and version from metadata - let id = project - .metadata - .as_ref() - .and_then(|m| m.id.as_ref()) - .cloned(); - let ver = project - .metadata - .as_ref() - .and_then(|m| m.version.as_ref()) - .cloned(); - // Dependencies are available in project.dependencies if needed (T043, T044) - (id, ver) - } - Err(_) => { - // If parsing fails, fall back to directory name and passed version - (None, None) - } - } - } else { - (None, None) - }; - - // Get skill ID - priority: override > skill-project.toml > directory name - let skill_id = if let Some(id) = skill_id_override { - id.to_string() - } else if let Some(id) = skill_id { - id - } else { - skill_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| { - ServiceError::Custom("Cannot determine skill ID from directory name".to_string()) - })? - .to_string() - }; - - // Get version - priority: skill-project.toml > passed version parameter - let package_version = package_version.unwrap_or_else(|| version.to_string()); - - // Create output directory if it doesn't exist - fs::create_dir_all(output_dir).map_err(ServiceError::Io)?; - - // Create ZIP file path (skill_id should not contain slashes) - let zip_filename = format!("{}-{}.zip", skill_id, package_version); - let zip_path = output_dir.join(&zip_filename); - - // Create ZIP file - let file = fs::File::create(&zip_path).map_err(ServiceError::Io)?; - - let mut zip = ZipWriter::new(file); - let options = FileOptions::default() - .compression_method(CompressionMethod::Deflated) - .unix_permissions(0o755); - - // Walk through skill directory and add files - let entries = walkdir::WalkDir::new(skill_path) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()); - - for entry in entries { - let file_path = entry.path(); - let relative_path = file_path - .strip_prefix(skill_path) - .map_err(|e| ServiceError::Custom(format!("Failed to get relative path: {}", e)))?; - - let relative_path_str = relative_path.to_string_lossy(); - - // Skip .git files - if relative_path_str.contains(".git/") { - continue; - } - - // Skip evals/ directory from published artifacts - if relative_path_str.starts_with("evals/") || relative_path_str == "evals" { - continue; - } - - // Read file content - let mut file_content = Vec::new(); - let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?; - file.read_to_end(&mut file_content) - .map_err(ServiceError::Io)?; - - // Add file to ZIP - zip.start_file(relative_path_str.as_ref(), options) - .map_err(|e| ServiceError::Custom(format!("Failed to add file to ZIP: {}", e)))?; - zip.write_all(&file_content).map_err(ServiceError::Io)?; - } - - // Get git commit if available - let git_commit = get_git_commit().ok(); - - // Create build metadata (use skill_id and package_version from skill-project.toml if available) - let build_metadata = create_build_metadata(&skill_id, &package_version, git_commit.as_deref()); - - // Add BUILD_INFO.json to ZIP - let build_info_json = serde_json::to_string_pretty(&build_metadata) - .map_err(|e| ServiceError::Custom(format!("Failed to serialize build metadata: {}", e)))?; - - zip.start_file("BUILD_INFO.json", options) - .map_err(|e| ServiceError::Custom(format!("Failed to add BUILD_INFO.json: {}", e)))?; - zip.write_all(build_info_json.as_bytes()) - .map_err(ServiceError::Io)?; - - // Finish ZIP first (without checksum) to calculate checksum - zip.finish() - .map_err(|e| ServiceError::Custom(format!("Failed to finalize ZIP: {}", e)))?; - - // Calculate checksum of the ZIP file (before adding checksum file) - // This checksum represents the ZIP contents without the checksum file itself - let checksum = calculate_checksum(&zip_path)?; - - // Now recreate the ZIP with checksum included - // Remove the old ZIP - fs::remove_file(&zip_path).map_err(ServiceError::Io)?; - - // Create new ZIP with checksum - let file = fs::File::create(&zip_path).map_err(ServiceError::Io)?; - - let mut zip = ZipWriter::new(file); - - // Re-add all files from skill directory - let entries = walkdir::WalkDir::new(skill_path) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()); - - for entry in entries { - let file_path = entry.path(); - let relative_path = file_path - .strip_prefix(skill_path) - .map_err(|e| ServiceError::Custom(format!("Failed to get relative path: {}", e)))?; - - let relative_path_str = relative_path.to_string_lossy(); - - if relative_path_str.contains(".git/") { - continue; - } - - // Skip evals/ directory from published artifacts - if relative_path_str.starts_with("evals/") || relative_path_str == "evals" { - continue; - } - - let mut file_content = Vec::new(); - let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?; - file.read_to_end(&mut file_content) - .map_err(ServiceError::Io)?; - - zip.start_file(relative_path_str.as_ref(), options) - .map_err(|e| ServiceError::Custom(format!("Failed to add file to ZIP: {}", e)))?; - zip.write_all(&file_content).map_err(ServiceError::Io)?; - } - - // Add BUILD_INFO.json (with checksum included in metadata) - // Note: checksum is stored separately in CHECKSUM.sha256 file - - let build_info_json = serde_json::to_string_pretty(&build_metadata) - .map_err(|e| ServiceError::Custom(format!("Failed to serialize build metadata: {}", e)))?; - - zip.start_file("BUILD_INFO.json", options) - .map_err(|e| ServiceError::Custom(format!("Failed to add BUILD_INFO.json: {}", e)))?; - zip.write_all(build_info_json.as_bytes()) - .map_err(ServiceError::Io)?; - - // Add CHECKSUM.sha256 (checksum of ZIP before this file was added) - zip.start_file("CHECKSUM.sha256", options) - .map_err(|e| ServiceError::Custom(format!("Failed to add CHECKSUM.sha256: {}", e)))?; - zip.write_all(checksum.as_bytes()) - .map_err(ServiceError::Io)?; - - zip.finish().map_err(|e| { - ServiceError::Custom(format!("Failed to finalize ZIP with checksum: {}", e)) - })?; - - Ok(zip_path) -} - -/// Create build metadata for a skill -pub fn create_build_metadata( - skill_id: &str, - version: &str, - git_commit: Option<&str>, -) -> BuildMetadata { - BuildMetadata { - skill_id: skill_id.to_string(), - version: version.to_string(), - build_timestamp: Utc::now().to_rfc3339(), - git_commit: git_commit.map(|s| s.to_string()), - git_branch: get_git_branch().ok(), - build_environment: BuildEnvironment { - fastskill_version: env!("CARGO_PKG_VERSION").to_string(), - rust_version: get_rust_version(), - }, - } -} - -/// Calculate SHA256 checksum of a file -pub fn calculate_checksum(file_path: &Path) -> Result { - let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?; - - let mut hasher = Sha256::new(); - let mut buffer = [0; 8192]; - - loop { - let bytes_read = file.read(&mut buffer).map_err(ServiceError::Io)?; - - if bytes_read == 0 { - break; - } - - hasher.update(&buffer[..bytes_read]); - } - - let hash = format!("sha256:{:x}", hasher.finalize()); - Ok(hash) -} - -/// Get git commit hash -fn get_git_commit() -> Result { - use std::process::Command; - - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .map_err(|e| ServiceError::Custom(format!("Failed to execute git: {}", e)))?; - - if !output.status.success() { - return Err(ServiceError::Custom( - "Failed to get git commit hash".to_string(), - )); - } - - let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(commit_hash) -} - -/// Get git branch name -fn get_git_branch() -> Result { - use std::process::Command; - - let output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .output() - .map_err(|e| ServiceError::Custom(format!("Failed to execute git: {}", e)))?; - - if !output.status.success() { - return Err(ServiceError::Custom( - "Failed to get git branch name".to_string(), - )); - } - - let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(branch_name) -} - -/// Get Rust version -fn get_rust_version() -> String { - // Try to get from environment or use a default - std::env::var("RUSTC_VERSION").unwrap_or_else(|_| "unknown".to_string()) -} - -/// Build metadata structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildMetadata { - pub skill_id: String, - pub version: String, - pub build_timestamp: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub git_commit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub git_branch: Option, - pub build_environment: BuildEnvironment, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildEnvironment { - pub fastskill_version: String, - pub rust_version: String, -} diff --git a/crates/fastskill-core/src/core/registry.rs b/crates/fastskill-core/src/core/registry.rs index bd24db22..909ceb43 100644 --- a/crates/fastskill-core/src/core/registry.rs +++ b/crates/fastskill-core/src/core/registry.rs @@ -3,9 +3,6 @@ pub mod auth; pub mod client; pub mod config; -pub mod index_manager; -pub mod staging; -pub mod validation_worker; pub use auth::{ApiKey, Auth, GitHubPat, SshKey}; pub use client::{IndexEntry, RegistryClient}; @@ -13,5 +10,3 @@ pub use config::{ AuthConfig, DefaultRegistryConfig, RegistriesConfig, RegistryConfig, RegistryConfigManager, StorageConfig, }; -pub use staging::{StagingManager, StagingMetadata, StagingStatus}; -pub use validation_worker::{ValidationWorker, ValidationWorkerConfig}; diff --git a/crates/fastskill-core/src/core/registry/index_manager.rs b/crates/fastskill-core/src/core/registry/index_manager.rs deleted file mode 100644 index 036ed78c..00000000 --- a/crates/fastskill-core/src/core/registry/index_manager.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! Atomic index update manager with file locking - -use crate::core::registry_index::{ - get_skill_index_lock_path, get_skill_index_path, ScopedSkillName, VersionEntry, -}; -use crate::core::service::ServiceError; -use fs2::FileExt; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; -use std::time::{Duration, Instant, SystemTime}; -use tracing::{info, warn}; - -/// Index manager for atomic updates with file locking -pub struct IndexManager { - /// Base path to the registry index directory - registry_path: PathBuf, - /// Maximum time to wait for file lock acquisition (default: 30 seconds) - lock_timeout: Duration, - /// Tracked file metadata for external modification detection (interior mutability) - file_metadata: Mutex>, -} - -/// Metadata for tracking index file state (for external modification detection) -#[derive(Debug, Clone)] -struct IndexFileMetadata { - /// File modification time - mtime: SystemTime, - /// File size in bytes - size: u64, -} - -/// Derive a per-call unique temp path next to the index file (same directory, so the -/// final rename stays atomic on one filesystem). -/// -/// Unlike `utils::atomic_write`, whose temp name doubles as its advisory lock token and -/// so must be deterministic, the index writer holds its own sidecar lock across the whole -/// read-modify-write — so its temp file need not lock, and it MUST be unique: a -/// deterministic `org/data.tmp` could equal the real index file of skill `org/data.tmp` -/// and be renamed away. Tagging the name with the process id and a monotonic counter -/// makes that collision impossible in practice. -fn unique_temp_path(index_path: &Path) -> PathBuf { - static SEQ: AtomicU64 = AtomicU64::new(0); - let seq = SEQ.fetch_add(1, Ordering::Relaxed); - crate::utils::append_suffix(index_path, &format!("{}.{}.tmp", std::process::id(), seq)) -} - -/// Returns true if `e` is an out-of-disk-space error (ENOSPC). -fn is_disk_full(e: &std::io::Error) -> bool { - if e.raw_os_error() == Some(28) { - return true; - } - let msg = e.to_string().to_lowercase(); - msg.contains("no space") || msg.contains("filesystem full") -} - -impl IndexManager { - /// Create a new IndexManager instance - /// - /// # Arguments - /// * `registry_path` - Base path to the registry index directory - /// - /// # Returns - /// Configured IndexManager instance with default 30-second lock timeout - pub fn new(registry_path: PathBuf) -> Self { - Self { - registry_path, - lock_timeout: Duration::from_secs(30), - file_metadata: Mutex::new(std::collections::HashMap::new()), - } - } - - /// Atomically update the index file for a skill version - /// - /// This method: - /// 1. Normalizes the skill_id using ScopedSkillName::normalize() - /// 2. Checks for duplicate versions - /// 3. Acquires an exclusive file lock with timeout - /// 4. Reads existing entries - /// 5. Appends new entry - /// 6. Writes to temporary file - /// 7. Atomically renames temporary file to target - /// 8. Releases lock - /// - /// # Arguments - /// * `skill_id` - The skill identifier (may be scoped, e.g., `@org/package`) - /// * `version` - The version string (e.g., `1.0.0`) - /// * `entry` - The version entry to add to the index - /// - /// # Returns - /// `Ok(())` if successful, `Err(ServiceError)` if operation fails - /// - /// # Errors - /// - Returns `ServiceError::Custom` if duplicate version is detected - /// - Returns `ServiceError::Custom` if lock timeout is exceeded - /// - Returns `ServiceError::Io` for filesystem errors - pub fn atomic_update( - &self, - skill_id: &str, - version: &str, - entry: &VersionEntry, - ) -> Result<(), ServiceError> { - // Step 1: Normalize scoped name - let normalized_id = ScopedSkillName::normalize(skill_id); - info!("Normalized skill_id '{}' to '{}'", skill_id, normalized_id); - - // Step 2: Get index file path - let index_path = get_skill_index_path(&self.registry_path, &normalized_id)?; - - // Ensure parent directory exists - if let Some(parent) = index_path.parent() { - std::fs::create_dir_all(parent).map_err(ServiceError::Io)?; - } - - // Step 3: Check for duplicate version (before acquiring lock) - // Read existing entries if file exists - let existing_entries_before_lock = if index_path.exists() { - Self::read_entries_from_path(&index_path)? - } else { - Vec::new() - }; - - // Check if version already exists - for existing_entry in &existing_entries_before_lock { - if existing_entry.vers == version { - return Err(ServiceError::Custom(format!( - "Version {} already exists for skill {}", - version, normalized_id - ))); - } - } - - // Step 4: Acquire exclusive lock with timeout. Use a sidecar lock file - // because the index file is replaced by atomic rename during update. - let lock_path = get_skill_index_lock_path(&self.registry_path, &normalized_id)?; - let lock_start = Instant::now(); - let file = loop { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(ServiceError::Io)?; - - // Try to acquire exclusive lock - match file.try_lock_exclusive() { - Ok(()) => { - let elapsed = lock_start.elapsed(); - if elapsed.as_millis() > 0 { - info!( - "Acquired lock for index file: {:?} (waited {}ms)", - index_path, - elapsed.as_millis() - ); - } else { - info!("Acquired lock for index file: {:?}", index_path); - } - break file; - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // Lock is held by another process, wait and retry - let elapsed = lock_start.elapsed(); - if elapsed >= self.lock_timeout { - warn!( - "Lock timeout exceeded for {:?} after {} seconds", - lock_path, - self.lock_timeout.as_secs() - ); - return Err(ServiceError::Custom(format!( - "Timeout waiting for file lock on {:?} (exceeded {} seconds)", - lock_path, - self.lock_timeout.as_secs() - ))); - } - // Log if we've been waiting a while - if elapsed.as_secs() > 0 && elapsed.as_secs().is_multiple_of(5) { - info!( - "Waiting for lock on {:?} ({}s elapsed, timeout: {}s)", - lock_path, - elapsed.as_secs(), - self.lock_timeout.as_secs() - ); - } - // Wait a bit before retrying - std::thread::sleep(Duration::from_millis(100)); - continue; - } - Err(e) => { - warn!("Failed to acquire lock on {:?}: {}", lock_path, e); - return Err(ServiceError::Io(e)); - } - } - }; - - // Lock will be released when file is dropped - // Use a guard to ensure lock is released even on error - struct LockGuard(File); - impl Drop for LockGuard { - fn drop(&mut self) { - if let Err(e) = self.0.unlock() { - warn!("Failed to release file lock: {}", e); - } - } - } - let _lock_guard = LockGuard(file); - - // Step 5: Check for external modifications (before reading) - if index_path.exists() { - let file_metadata = self.file_metadata.lock().map_err(|_| { - ServiceError::Custom( - "Mutex poisoned - another thread panicked while holding the lock".to_string(), - ) - })?; - if let Some(prev_metadata) = file_metadata.get(&index_path) { - match std::fs::metadata(&index_path) { - Ok(current_metadata) => { - let current_mtime = current_metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH); - let current_size = current_metadata.len(); - - if current_mtime != prev_metadata.mtime - || current_size != prev_metadata.size - { - warn!( - "External modification detected for {:?}: mtime changed from {:?} to {:?}, size changed from {} to {}", - index_path, prev_metadata.mtime, current_mtime, prev_metadata.size, current_size - ); - } - } - Err(e) => { - warn!("Failed to read metadata for {:?}: {}", index_path, e); - } - } - } - drop(file_metadata); // Release lock before reading - } - - // Step 5: Read existing entries again (after acquiring lock) - let mut existing_entries = if index_path.exists() { - Self::read_entries_from_path(&index_path)? - } else { - Vec::new() - }; - - // Update tracked metadata after successful read - if index_path.exists() { - if let Ok(metadata) = std::fs::metadata(&index_path) { - if let Ok(mtime) = metadata.modified() { - let mut file_metadata = self.file_metadata.lock().map_err(|_| { - ServiceError::Custom( - "Mutex poisoned - another thread panicked while holding the lock" - .to_string(), - ) - })?; - file_metadata.insert( - index_path.clone(), - IndexFileMetadata { - mtime, - size: metadata.len(), - }, - ); - } - } - } - - // Double-check for duplicate (in case it was added between initial check and lock) - for existing_entry in &existing_entries { - if existing_entry.vers == version { - warn!( - "Duplicate version {} detected for skill {} after acquiring lock", - version, normalized_id - ); - return Err(ServiceError::Custom(format!( - "Version {} already exists for skill {} (detected after lock)", - version, normalized_id - ))); - } - } - - info!( - "Updating index for {} v{} ({} existing entries)", - normalized_id, - version, - existing_entries.len() - ); - - // Step 6: Append the new entry and serialize the whole index as - // newline-delimited JSON. - existing_entries.push(entry.clone()); - let mut buffer = String::new(); - for entry in &existing_entries { - let line = serde_json::to_string(entry).map_err(|e| { - ServiceError::Custom(format!("Failed to serialize index entry: {}", e)) - })?; - buffer.push_str(&line); - buffer.push('\n'); - } - - // Step 7: Durably replace the index file — write to a unique temp, fsync, then - // atomically rename. A full filesystem only ever truncates the temp file, so the - // existing index is preserved on ENOSPC. - let temp_path = unique_temp_path(&index_path); - let on_write_err = |e: std::io::Error| { - let _ = std::fs::remove_file(&temp_path); - if is_disk_full(&e) { - warn!("Filesystem full: cannot update index for {normalized_id} v{version}"); - ServiceError::Custom(format!( - "Filesystem full: cannot update index for {normalized_id} v{version}. Existing index preserved." - )) - } else { - ServiceError::Io(e) - } - }; - - let mut temp_file = File::create(&temp_path).map_err(&on_write_err)?; - temp_file - .write_all(buffer.as_bytes()) - .map_err(&on_write_err)?; - temp_file.sync_all().map_err(&on_write_err)?; - drop(temp_file); - - // Step 8: Atomically rename the temp file into place. - std::fs::rename(&temp_path, &index_path).map_err(|e| { - warn!("Failed to rename temp file {temp_path:?} to {index_path:?}: {e}"); - let _ = std::fs::remove_file(&temp_path); - ServiceError::Io(e) - })?; - - info!( - "Successfully updated index for {} v{} (total {} entries)", - normalized_id, - version, - existing_entries.len() - ); - - Ok(()) - } - - /// Read version entries from an index file path - /// Helper function to read directly from a file path (not using registry_path + skill_id) - fn read_entries_from_path(index_path: &PathBuf) -> Result, ServiceError> { - use std::fs; - - if !index_path.exists() { - return Ok(Vec::new()); - } - - let content = fs::read_to_string(index_path).map_err(ServiceError::Io)?; - let mut entries = Vec::new(); - - // Parse line-by-line (newline-delimited JSON) - for line in content.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - - match serde_json::from_str::(line) { - Ok(entry) => entries.push(entry), - Err(e) => { - // Log error but continue parsing other lines - warn!("Failed to parse index entry: {} (line: {})", e, line); - } - } - } - - Ok(entries) - } -} - -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used)] -mod tests { - use super::*; - use crate::core::registry_index::VersionEntry; - use std::collections::HashMap; - use std::sync::Arc; - use tempfile::TempDir; - - fn entry_named(name: &str, version: &str) -> VersionEntry { - VersionEntry { - name: name.to_string(), - vers: version.to_string(), - deps: Vec::new(), - cksum: format!("checksum-{version}"), - features: HashMap::new(), - yanked: false, - links: None, - download_url: format!("https://example.com/test-skill-{version}.zip"), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - } - } - - fn entry(version: &str) -> VersionEntry { - entry_named("testorg/test-skill", version) - } - - fn read_entries(registry_path: PathBuf) -> Vec { - let index_path = get_skill_index_path(®istry_path, "testorg/test-skill").unwrap(); - IndexManager::read_entries_from_path(&index_path).unwrap() - } - - #[test] - fn atomic_update_preserves_existing_entries() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - for i in 0..25 { - let version = format!("1.0.{i}"); - let update = entry(&version); - let result = manager.atomic_update("testorg/test-skill", &version, &update); - assert!( - result.is_ok(), - "atomic_update failed for {version}: {result:?}" - ); - } - - let entries = read_entries(registry_path); - assert_eq!(entries.len(), 25); - } - - #[test] - fn atomic_update_serializes_concurrent_same_skill_writes() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let handles: Vec<_> = (0..10) - .map(|i| { - let manager = Arc::clone(&manager); - std::thread::spawn(move || { - let version = format!("1.0.{i}"); - let update = entry(&version); - manager.atomic_update("testorg/test-skill", &version, &update) - }) - }) - .collect(); - - for handle in handles { - let result = handle.join().unwrap(); - assert!( - result.is_ok(), - "concurrent atomic_update failed: {result:?}" - ); - } - - let entries = read_entries(registry_path); - assert_eq!(entries.len(), 10); - } - - /// Regression: two skills whose package names differ only after a dot must NOT - /// share a sidecar lock file. Before the fix, `with_extension("lock")` mapped both - /// `org/web.scraper` and `org/web.crawler` onto `org/web.lock`. - #[test] - fn atomic_update_uses_distinct_lock_files_for_dotted_names() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - manager - .atomic_update( - "org/web.scraper", - "1.0.0", - &entry_named("org/web.scraper", "1.0.0"), - ) - .unwrap(); - manager - .atomic_update( - "org/web.crawler", - "1.0.0", - &entry_named("org/web.crawler", "1.0.0"), - ) - .unwrap(); - - // Each skill keeps its own sidecar lock (append, not replace-extension). - assert!( - registry_path.join("org/web.scraper.lock").exists(), - "expected distinct lock for web.scraper" - ); - assert!( - registry_path.join("org/web.crawler.lock").exists(), - "expected distinct lock for web.crawler" - ); - } - - /// Regression: publishing a skill must not clobber another skill whose index file - /// happens to match the first skill's temp path. Before the fix, writing `org/data` - /// used temp `org/data.tmp` — the literal index file of skill `org/data.tmp` — and - /// the atomic rename destroyed it. - #[test] - fn atomic_update_does_not_clobber_skill_named_like_temp_path() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - manager - .atomic_update( - "org/data.tmp", - "1.0.0", - &entry_named("org/data.tmp", "1.0.0"), - ) - .unwrap(); - manager - .atomic_update("org/data", "1.0.0", &entry_named("org/data", "1.0.0")) - .unwrap(); - - let victim_index = get_skill_index_path(®istry_path, "org/data.tmp").unwrap(); - let victim = IndexManager::read_entries_from_path(&victim_index).unwrap(); - assert_eq!( - victim.len(), - 1, - "publishing org/data must not destroy the org/data.tmp index" - ); - assert_eq!(victim[0].name, "org/data.tmp"); - } -} diff --git a/crates/fastskill-core/src/core/registry/staging.rs b/crates/fastskill-core/src/core/registry/staging.rs deleted file mode 100644 index f9ef9188..00000000 --- a/crates/fastskill-core/src/core/registry/staging.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Staging area management for registry publishing - -use crate::core::service::ServiceError; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; -use uuid::Uuid; - -/// Staging metadata for a published package -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StagingMetadata { - /// Full skill ID in format scope/id (kept for backward compatibility) - pub skill_id: String, - /// Scope (e.g., "dev-user") - #[serde(default)] - pub scope: Option, - /// Package ID without scope (e.g., "test-skill") - #[serde(default)] - pub id: Option, - pub version: String, - pub checksum: String, - pub uploaded_at: DateTime, - pub uploaded_by: Option, - pub status: StagingStatus, - pub validation_errors: Vec, - pub job_id: String, -} - -/// Status of a package in staging -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum StagingStatus { - Pending, - Validating, - Accepted, - Rejected, -} - -impl StagingStatus { - pub fn as_str(&self) -> &'static str { - match self { - StagingStatus::Pending => "pending", - StagingStatus::Validating => "validating", - StagingStatus::Accepted => "accepted", - StagingStatus::Rejected => "rejected", - } - } -} - -/// Staging area manager -#[derive(Clone)] -pub struct StagingManager { - pub(crate) staging_dir: PathBuf, -} - -impl StagingManager { - /// Create a new staging manager - pub fn new(staging_dir: PathBuf) -> Self { - Self { staging_dir } - } - - /// Initialize staging directory structure - pub fn initialize(&self) -> Result<(), ServiceError> { - fs::create_dir_all(&self.staging_dir).map_err(ServiceError::Io)?; - Ok(()) - } - - /// Get staging path for a skill version - /// Accepts scope and id separately - /// Returns an error if any component contains path traversal attempts - pub fn get_staging_path( - &self, - scope: &str, - id: &str, - version: &str, - ) -> Result { - let scope_safe = crate::security::validate_path_component(scope) - .map_err(|e| ServiceError::Validation(format!("Invalid scope: {}", e)))?; - let id_safe = crate::security::validate_path_component(id) - .map_err(|e| ServiceError::Validation(format!("Invalid id: {}", e)))?; - let version_safe = crate::security::validate_path_component(version) - .map_err(|e| ServiceError::Validation(format!("Invalid version: {}", e)))?; - Ok(self - .staging_dir - .join(scope_safe) - .join(id_safe) - .join(version_safe)) - } - - /// Store package in staging area - /// Accepts scope and id separately - pub async fn store_package( - &self, - scope: &str, - id: &str, - version: &str, - package_data: &[u8], - uploaded_by: Option<&str>, - ) -> Result<(PathBuf, String), ServiceError> { - let staging_path = self.get_staging_path(scope, id, version)?; - tracing::info!( - "StagingManager: creating directory: {}", - staging_path.display() - ); - match fs::create_dir_all(&staging_path) { - Ok(()) => { - tracing::info!( - "StagingManager: successfully created directory: {}", - staging_path.display() - ); - } - Err(e) => { - tracing::error!( - "StagingManager: failed to create directory {}: {}", - staging_path.display(), - e - ); - tracing::error!("StagingManager: error kind: {:?}", e.kind()); - tracing::error!("StagingManager: raw OS error: {:?}", e.raw_os_error()); - // Check if parent directories exist - if let Some(parent) = staging_path.parent() { - tracing::error!( - "StagingManager: parent directory exists: {}", - parent.exists() - ); - tracing::error!("StagingManager: parent path: {}", parent.display()); - } - return Err(ServiceError::Io(e)); - } - } - - // Generate job ID - let job_id = format!("job_{}", Uuid::new_v4().simple()); - - // Calculate checksum - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(package_data); - let checksum = format!("sha256:{:x}", hasher.finalize()); - - // Validate id and version for safe filename construction - let id_safe = crate::security::validate_path_component(id) - .map_err(|e| ServiceError::Custom(format!("Invalid id: {}", e)))?; - let version_safe = crate::security::validate_path_component(version) - .map_err(|e| ServiceError::Custom(format!("Invalid version: {}", e)))?; - - // Use validated id and version for package filename - let package_filename = format!("{}-{}.zip", id_safe, version_safe); - let package_path = staging_path.join(&package_filename); - tracing::info!( - "StagingManager: writing package file: {}", - package_path.display() - ); - fs::write(&package_path, package_data).map_err(ServiceError::Io)?; - - // Create metadata with scope and id stored separately - let full_skill_id = format!("{}/{}", scope, id); - let metadata = StagingMetadata { - skill_id: full_skill_id.clone(), - scope: Some(scope.to_string()), - id: Some(id.to_string()), - version: version.to_string(), - checksum, - uploaded_at: Utc::now(), - uploaded_by: uploaded_by.map(|s| s.to_string()), - status: StagingStatus::Pending, - validation_errors: Vec::new(), - job_id: job_id.clone(), - }; - - // Write metadata - let metadata_path = staging_path.join("metadata.json"); - tracing::info!( - "StagingManager: writing metadata file: {}", - metadata_path.display() - ); - let metadata_json = serde_json::to_string_pretty(&metadata) - .map_err(|e| ServiceError::Custom(format!("Failed to serialize metadata: {}", e)))?; - fs::write(&metadata_path, metadata_json).map_err(ServiceError::Io)?; - - Ok((staging_path, job_id)) - } - - /// Load metadata for a job - pub fn load_metadata(&self, job_id: &str) -> Result, ServiceError> { - // Validate job_id to prevent path traversal - if job_id.contains("..") || job_id.contains('/') || job_id.contains('\\') { - return Err(ServiceError::Custom(format!("Invalid job_id: {}", job_id))); - } - - // Search for metadata with matching job_id - if !self.staging_dir.exists() { - return Ok(None); - } - - for entry in walkdir::WalkDir::new(&self.staging_dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() == "metadata.json") - { - let metadata_path = entry.path(); - if let Ok(content) = fs::read_to_string(metadata_path) { - if let Ok(metadata) = serde_json::from_str::(&content) { - if metadata.job_id == job_id { - return Ok(Some(metadata)); - } - } - } - } - - Ok(None) - } - - /// Update metadata status - pub fn update_status( - &self, - job_id: &str, - status: StagingStatus, - validation_errors: Vec, - ) -> Result<(), ServiceError> { - let metadata = self - .load_metadata(job_id)? - .ok_or_else(|| ServiceError::Custom(format!("Job {} not found", job_id)))?; - - // Extract scope and id from metadata - let (scope, id) = if let (Some(s), Some(i)) = (metadata.scope.clone(), metadata.id.clone()) - { - (s, i) - } else { - // Fallback: extract from skill_id for backward compatibility - let parts: Vec<&str> = metadata.skill_id.split('/').collect(); - if parts.len() >= 2 { - (parts[0].to_string(), parts[1..].join("/")) - } else { - return Err(ServiceError::Custom(format!( - "Invalid skill_id format in metadata: {}", - metadata.skill_id - ))); - } - }; - - // Validate id and version for safe path construction - let id_safe = crate::security::validate_path_component(&id) - .map_err(|e| ServiceError::Custom(format!("Invalid id: {}", e)))?; - let version_safe = crate::security::validate_path_component(&metadata.version) - .map_err(|e| ServiceError::Custom(format!("Invalid version: {}", e)))?; - - let staging_path = self.get_staging_path(&scope, &id_safe, &version_safe)?; - let metadata_path = staging_path.join("metadata.json"); - - let updated_metadata = StagingMetadata { - status, - validation_errors, - ..metadata - }; - - let metadata_json = serde_json::to_string_pretty(&updated_metadata) - .map_err(|e| ServiceError::Custom(format!("Failed to serialize metadata: {}", e)))?; - fs::write(&metadata_path, metadata_json).map_err(ServiceError::Io)?; - - Ok(()) - } - - /// Get package path for a job - pub fn get_package_path(&self, job_id: &str) -> Result, ServiceError> { - let metadata = self - .load_metadata(job_id)? - .ok_or_else(|| ServiceError::Custom(format!("Job {} not found", job_id)))?; - - // Extract scope and id from metadata - let (scope, id) = if let (Some(s), Some(i)) = (metadata.scope.clone(), metadata.id.clone()) - { - (s, i) - } else { - // Fallback: extract from skill_id for backward compatibility - let parts: Vec<&str> = metadata.skill_id.split('/').collect(); - if parts.len() >= 2 { - (parts[0].to_string(), parts[1..].join("/")) - } else { - return Err(ServiceError::Custom(format!( - "Invalid skill_id format in metadata: {}", - metadata.skill_id - ))); - } - }; - - // Validate id and version to prevent path traversal - let id_safe = crate::security::validate_path_component(&id) - .map_err(|e| ServiceError::Custom(format!("Invalid id: {}", e)))?; - let version_safe = crate::security::validate_path_component(&metadata.version) - .map_err(|e| ServiceError::Custom(format!("Invalid version: {}", e)))?; - - let staging_path = self.get_staging_path(&scope, &id_safe, &version_safe)?; - // Use validated id and version for filename (must match store_package) - let package_filename = format!("{}-{}.zip", id_safe, version_safe); - let package_path = staging_path.join(&package_filename); - - if package_path.exists() { - Ok(Some(package_path)) - } else { - Ok(None) - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_store_and_load_package() { - let temp_dir = TempDir::new().unwrap(); - let manager = StagingManager::new(temp_dir.path().to_path_buf()); - manager.initialize().unwrap(); - - let package_data = b"test package data"; - let (staging_path, job_id) = manager - .store_package( - "test-scope", - "test-skill", - "1.0.0", - package_data, - Some("user1"), - ) - .await - .unwrap(); - - assert!(staging_path.join("test-skill-1.0.0.zip").exists()); - assert!(staging_path.join("metadata.json").exists()); - - let metadata = manager.load_metadata(&job_id).unwrap().unwrap(); - assert_eq!(metadata.skill_id, "test-scope/test-skill"); - assert_eq!(metadata.scope, Some("test-scope".to_string())); - assert_eq!(metadata.id, Some("test-skill".to_string())); - assert_eq!(metadata.version, "1.0.0"); - assert_eq!(metadata.status, StagingStatus::Pending); - assert_eq!(metadata.uploaded_by, Some("user1".to_string())); - } - - #[tokio::test] - async fn test_get_staging_path_rejects_traversal_components() { - let temp_dir = TempDir::new().unwrap(); - let manager = StagingManager::new(temp_dir.path().to_path_buf()); - manager.initialize().unwrap(); - - // Test that get_staging_path rejects ".." components - let result = manager.get_staging_path("..", "id", "1.0.0"); - assert!(result.is_err()); - - let result = manager.get_staging_path("scope", "..", "1.0.0"); - assert!(result.is_err()); - - let result = manager.get_staging_path("scope", "id", ".."); - assert!(result.is_err()); - } -} diff --git a/crates/fastskill-core/src/core/registry/validation_worker.rs b/crates/fastskill-core/src/core/registry/validation_worker.rs deleted file mode 100644 index d3da5782..00000000 --- a/crates/fastskill-core/src/core/registry/validation_worker.rs +++ /dev/null @@ -1,649 +0,0 @@ -//! Validation worker for processing staged packages - -use crate::core::blob_storage::{create_blob_storage, BlobStorageConfig}; -use crate::core::metadata::parse_yaml_frontmatter; -use crate::core::registry::index_manager::IndexManager; -use crate::core::registry::staging::{StagingManager, StagingStatus}; -use crate::core::registry_index::{get_version_metadata, IndexMetadata, VersionEntry}; -use crate::core::service::ServiceError; -use crate::validation::{SkillValidator, ZipValidator}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::time::{sleep, Duration}; -use tracing::{error, info, warn}; -use zip::ZipArchive; - -/// Validation worker configuration -#[derive(Debug, Clone)] -pub struct ValidationWorkerConfig { - /// Poll interval in seconds - pub poll_interval_secs: u64, - - /// Blob storage configuration - pub blob_storage_config: Option, - - /// Registry index path - pub registry_index_path: Option, - - /// Base URL for blob storage downloads - pub blob_base_url: Option, -} - -impl Default for ValidationWorkerConfig { - fn default() -> Self { - Self { - poll_interval_secs: 5, - blob_storage_config: None, - registry_index_path: None, - blob_base_url: None, - } - } -} - -/// Validation worker for processing staged packages -pub struct ValidationWorker { - staging_manager: StagingManager, - config: ValidationWorkerConfig, - skill_validator: Arc, - zip_validator: Arc, - running: Arc, -} - -impl ValidationWorker { - /// Create a new validation worker - pub fn new(staging_manager: StagingManager, config: ValidationWorkerConfig) -> Self { - Self { - staging_manager, - config, - skill_validator: Arc::new(SkillValidator::new()), - zip_validator: Arc::new(ZipValidator::new()), - running: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } - - /// Start the validation worker (runs in background) - pub fn start(&self) { - let staging_manager = self.staging_manager.clone(); - let config = self.config.clone(); - let skill_validator = self.skill_validator.clone(); - let zip_validator = self.zip_validator.clone(); - let running = self.running.clone(); - - running.store(true, std::sync::atomic::Ordering::SeqCst); - - tokio::spawn(async move { - info!("Validation worker started"); - - while running.load(std::sync::atomic::Ordering::SeqCst) { - if let Err(e) = Self::process_pending_packages( - &staging_manager, - &config, - &skill_validator, - &zip_validator, - ) - .await - { - error!("Error processing pending packages: {}", e); - } - - sleep(Duration::from_secs(config.poll_interval_secs)).await; - } - - info!("Validation worker stopped"); - }); - } - - /// Stop the validation worker - pub fn stop(&self) { - self.running - .store(false, std::sync::atomic::Ordering::SeqCst); - } - - /// Start the validation worker with graceful shutdown via cancellation token - pub fn start_with_shutdown(&self, shutdown: tokio_util::sync::CancellationToken) { - let staging_manager = self.staging_manager.clone(); - let config = self.config.clone(); - let skill_validator = self.skill_validator.clone(); - let zip_validator = self.zip_validator.clone(); - let running = self.running.clone(); - - running.store(true, std::sync::atomic::Ordering::SeqCst); - - tokio::spawn(async move { - info!("Validation worker started"); - loop { - if let Err(e) = Self::process_pending_packages( - &staging_manager, - &config, - &skill_validator, - &zip_validator, - ) - .await - { - error!("Error processing pending packages: {}", e); - } - tokio::select! { - _ = shutdown.cancelled() => break, - _ = tokio::time::sleep( - tokio::time::Duration::from_secs(config.poll_interval_secs) - ) => {}, - } - } - running.store(false, std::sync::atomic::Ordering::SeqCst); - info!("Validation worker stopped"); - }); - } - - /// Process all pending packages - async fn process_pending_packages( - staging_manager: &StagingManager, - config: &ValidationWorkerConfig, - skill_validator: &Arc, - zip_validator: &Arc, - ) -> Result<(), ServiceError> { - // Find all pending packages - let pending_jobs = Self::find_pending_jobs(staging_manager)?; - - for job_id in pending_jobs { - info!("Processing job: {}", job_id); - - // Update status to validating - staging_manager.update_status(&job_id, StagingStatus::Validating, Vec::new())?; - - // Validate package - match Self::validate_package(staging_manager, &job_id, skill_validator, zip_validator) - .await - { - Ok(()) => { - // Package is valid, move to blob storage and update index - if let Err(e) = Self::accept_and_publish(staging_manager, &job_id, config).await - { - error!("Failed to publish package {}: {}", job_id, e); - staging_manager.update_status( - &job_id, - StagingStatus::Rejected, - vec![format!("Failed to publish: {}", e)], - )?; - } else { - info!("Package {} accepted and published", job_id); - } - } - Err(e) => { - warn!("Package {} validation failed: {}", job_id, e); - staging_manager.update_status( - &job_id, - StagingStatus::Rejected, - vec![e.to_string()], - )?; - } - } - } - - Ok(()) - } - - /// Find all pending jobs in staging - fn find_pending_jobs(staging_manager: &StagingManager) -> Result, ServiceError> { - use std::fs; - use walkdir::WalkDir; - - let staging_dir = &staging_manager.staging_dir; - if !staging_dir.exists() { - return Ok(Vec::new()); - } - - let mut pending_jobs = Vec::new(); - - for entry in WalkDir::new(staging_dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() == "metadata.json") - { - if let Ok(content) = fs::read_to_string(entry.path()) { - if let Ok(metadata) = serde_json::from_str::< - crate::core::registry::staging::StagingMetadata, - >(&content) - { - if metadata.status == StagingStatus::Pending { - pending_jobs.push(metadata.job_id); - } - } - } - } - - Ok(pending_jobs) - } - - /// Validate a package - async fn validate_package( - staging_manager: &StagingManager, - job_id: &str, - _skill_validator: &Arc, - zip_validator: &Arc, - ) -> Result<(), ServiceError> { - use crate::validation::standard_validator::StandardValidator; - - // Get package path - let package_path = staging_manager - .get_package_path(job_id)? - .ok_or_else(|| ServiceError::Custom(format!("Package not found for job {}", job_id)))?; - - // Validate ZIP structure - zip_validator.validate_zip_package(&package_path).await?; - - // Extract ZIP to temporary directory for comprehensive validation - let temp_dir = tempfile::TempDir::new().map_err(|e| { - ServiceError::Io(std::io::Error::other(format!( - "Failed to create temp dir: {}", - e - ))) - })?; - - Self::extract_zip_to_temp(&package_path, temp_dir.path())?; - - // Find the skill directory (should be the root or a subdirectory) - let skill_dir = Self::find_skill_directory(temp_dir.path())?; - - // Use StandardValidator for comprehensive AI Skill standard validation - let validation_result = StandardValidator::validate_skill_directory(&skill_dir)?; - - if !validation_result.is_valid { - let error_messages: Vec = validation_result.errors.iter().map(|e| { - match e { - crate::validation::standard_validator::ValidationError::InvalidNameFormat(msg) => - format!("✗ Name format invalid: {}", msg), - crate::validation::standard_validator::ValidationError::NameMismatch { expected, actual } => - format!("✗ Name mismatch: Directory '{}' doesn't match skill name '{}'", actual, expected), - crate::validation::standard_validator::ValidationError::InvalidDescriptionLength(len) => - format!("✗ Description length invalid: {} characters (must be 1-1024)", len), - crate::validation::standard_validator::ValidationError::InvalidCompatibilityLength(len) => - format!("✗ Compatibility field too long: {} characters (max 500)", len), - crate::validation::standard_validator::ValidationError::MissingRequiredField(field) => - format!("✗ Missing required field: {}", field), - crate::validation::standard_validator::ValidationError::InvalidFileReference(msg) => - format!("✗ Invalid file reference: {}", msg), - crate::validation::standard_validator::ValidationError::InvalidDirectoryStructure(msg) => - format!("✗ Invalid directory structure: {}", msg), - crate::validation::standard_validator::ValidationError::YamlParseError(msg) => - format!("✗ YAML parsing error: {}", msg), - } - }).collect(); - - return Err(ServiceError::Validation(error_messages.join("\n"))); - } - - // Log warnings even if validation passes - for warning in &validation_result.warnings { - warn!("Package validation warning for {}: {}", job_id, warning); - } - - Ok(()) - } - - /// Extract ZIP to temporary directory - fn extract_zip_to_temp( - package_path: &std::path::Path, - temp_dir: &std::path::Path, - ) -> Result<(), ServiceError> { - use crate::storage::zip::ZipHandler; - let zip_handler = ZipHandler::new().map_err(|e| { - ServiceError::Validation(format!("Failed to create ZIP handler: {}", e)) - })?; - zip_handler.extract_to_dir(package_path, temp_dir) - } - - /// Find skill directory in extracted ZIP - fn find_skill_directory( - temp_dir: &std::path::Path, - ) -> Result { - // Look for SKILL.md at the root level first - let skill_md_path = temp_dir.join("SKILL.md"); - if skill_md_path.exists() { - return Ok(temp_dir.to_path_buf()); - } - - // Look for SKILL.md in subdirectories (in case ZIP has a containing folder) - for entry in std::fs::read_dir(temp_dir).map_err(ServiceError::Io)? { - let entry = entry.map_err(ServiceError::Io)?; - let path = entry.path(); - - if path.is_dir() { - let skill_md_in_subdir = path.join("SKILL.md"); - if skill_md_in_subdir.exists() { - return Ok(path); - } - } - } - - Err(ServiceError::Validation( - "Could not find SKILL.md in extracted package".to_string(), - )) - } - - /// Extract SKILL.md from ZIP - fn extract_skill_md(package_path: &PathBuf) -> Result { - use std::io::Cursor; - use std::io::Read; - - let zip_data = std::fs::read(package_path).map_err(ServiceError::Io)?; - - let cursor = Cursor::new(zip_data); - let mut archive = ZipArchive::new(cursor) - .map_err(|e| ServiceError::Validation(format!("Invalid ZIP file: {}", e)))?; - - let mut skill_content = String::new(); - for i in 0..archive.len() { - let file = archive.by_index(i).map_err(|e| { - ServiceError::Validation(format!("Failed to read ZIP entry: {}", e)) - })?; - - if file.name().ends_with("SKILL.md") { - let mut reader = std::io::BufReader::new(file); - reader.read_to_string(&mut skill_content).map_err(|e| { - ServiceError::Validation(format!("Failed to read SKILL.md: {}", e)) - })?; - break; - } - } - - if skill_content.is_empty() { - return Err(ServiceError::Validation( - "SKILL.md not found in package".to_string(), - )); - } - - Ok(skill_content) - } - - /// Accept package and publish to blob storage + registry index - /// - /// **Blob-First Persistence**: This method enforces that blob storage upload - /// must succeed before any index update is attempted. If blob upload fails, - /// the method returns an error and no index update occurs. - async fn accept_and_publish( - staging_manager: &StagingManager, - job_id: &str, - config: &ValidationWorkerConfig, - ) -> Result<(), ServiceError> { - // Load metadata - let metadata = staging_manager - .load_metadata(job_id)? - .ok_or_else(|| ServiceError::Custom(format!("Job {} not found", job_id)))?; - - // Extract scope and id from metadata - let (scope, id) = if let (Some(s), Some(i)) = (metadata.scope.clone(), metadata.id.clone()) - { - (s, i) - } else { - // Fallback: extract from skill_id for backward compatibility - let parts: Vec<&str> = metadata.skill_id.split('/').collect(); - if parts.len() >= 2 { - (parts[0].to_string(), parts[1..].join("/")) - } else { - return Err(ServiceError::Custom(format!( - "Invalid skill_id format in metadata: {}", - metadata.skill_id - ))); - } - }; - - // Get package path - let package_path = staging_manager - .get_package_path(job_id)? - .ok_or_else(|| ServiceError::Custom(format!("Package not found for job {}", job_id)))?; - - // STEP 1: Upload to blob storage FIRST (blob-first persistence) - // If this fails, we abort and return error - no index update will occur - let blob_url = if let Some(ref blob_config) = config.blob_storage_config { - info!("Uploading package {} to blob storage...", job_id); - - let storage = create_blob_storage(&blob_config.storage_type, blob_config) - .await - .map_err(|e| { - error!("Failed to create blob storage for job {}: {}", job_id, e); - ServiceError::Custom(format!("Failed to create blob storage: {}", e)) - })?; - - let package_filename = package_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| ServiceError::Custom("Invalid package filename".to_string()))?; - - // Use scope from metadata for blob storage path - let storage_path = format!("skills/{}/{}", scope, package_filename); - let package_data = std::fs::read(&package_path).map_err(ServiceError::Io)?; - - // CRITICAL: Upload must succeed before proceeding to index update - storage - .upload(&storage_path, &package_data) - .await - .map_err(|e| { - error!( - "Blob storage upload failed for job {}: {}. Aborting index update.", - job_id, e - ); - ServiceError::Custom(format!( - "Failed to upload to blob storage: {}. Index update aborted.", - e - )) - })?; - - // Construct download URL - let blob_url = if let Some(base_url) = storage.base_url() { - format!("{}/{}", base_url.trim_end_matches('/'), storage_path) - } else if let Some(ref blob_base_url) = config.blob_base_url { - format!("{}/{}", blob_base_url.trim_end_matches('/'), storage_path) - } else { - format!("file://{}", package_path.display()) - }; - - info!( - "Successfully uploaded package {} to blob storage: {}", - job_id, blob_url - ); - Some(blob_url) - } else { - info!("Skipping blob storage upload (not configured)"); - None - }; - - // STEP 2: Update registry index ONLY if blob upload succeeded (or blob storage not configured) - // If blob storage was configured and upload failed, we would have returned error above - if let Some(ref registry_index_path) = config.registry_index_path { - let download_url = blob_url - .as_deref() - .unwrap_or_else(|| package_path.to_str().unwrap_or("")); - - // Extract metadata from package - let skill_content = Self::extract_skill_md(&package_path)?; - let frontmatter = parse_yaml_frontmatter(&skill_content).map_err(|e| { - ServiceError::Validation(format!("Failed to parse SKILL.md: {}", e)) - })?; - - // Get version metadata (use full skill_id in format scope/id) - let full_skill_id = format!("{}/{}", scope, id); - let version_metadata = - get_version_metadata(&full_skill_id, &package_path, download_url) - .map_err(|e| ServiceError::Custom(format!("Failed to get metadata: {}", e)))?; - - // Create VersionEntry for IndexManager - let version_entry = VersionEntry { - name: full_skill_id.clone(), - vers: metadata.version.clone(), - deps: version_metadata.deps.clone(), - cksum: version_metadata.cksum.clone(), - features: version_metadata.features.clone(), - yanked: false, - links: version_metadata.links.clone(), - download_url: download_url.to_string(), - published_at: version_metadata.published_at.clone(), - metadata: Some(IndexMetadata { - description: Some(frontmatter.description), - author: frontmatter.author, - license: None, - repository: None, - }), - scoped_name: if full_skill_id.contains('@') || full_skill_id.contains(':') { - Some(full_skill_id.clone()) - } else { - None - }, - }; - - // Use IndexManager for atomic update (skill_id in format scope/id) - let index_manager = IndexManager::new(registry_index_path.clone()); - index_manager.atomic_update(&full_skill_id, &metadata.version, &version_entry)?; - - info!( - "Updated registry index for {} v{}", - full_skill_id, metadata.version - ); - } else { - info!("Skipping registry index update (not configured)"); - } - - // Mark as accepted - staging_manager.update_status(job_id, StagingStatus::Accepted, Vec::new())?; - - Ok(()) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::TempDir; - use zip::write::FileOptions; - use zip::ZipWriter; - - #[tokio::test] - async fn test_start_with_shutdown_stops_cleanly() { - let temp_dir = TempDir::new().unwrap(); - let staging_manager = StagingManager::new(temp_dir.path().to_path_buf()); - staging_manager.initialize().unwrap(); - - let config = ValidationWorkerConfig { - poll_interval_secs: 1, - ..Default::default() - }; - - let worker = ValidationWorker::new(staging_manager, config); - let running = worker.running.clone(); - - let token = tokio_util::sync::CancellationToken::new(); - worker.start_with_shutdown(token.clone()); - - // running is set synchronously before spawn, so it should be true immediately - assert!(running.load(std::sync::atomic::Ordering::SeqCst)); - - // Cancel and wait for the spawned task to finish - token.cancel(); - for _ in 0..20 { - if !running.load(std::sync::atomic::Ordering::SeqCst) { - break; - } - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - } - assert!(!running.load(std::sync::atomic::Ordering::SeqCst)); - } - - /// Helper to create a test ZIP with malicious entries - fn create_malicious_zip(path: &std::path::Path) { - let file = std::fs::File::create(path).unwrap(); - let mut zip = ZipWriter::new(file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - - // Add legitimate content - zip.start_file("SKILL.md", options).unwrap(); - zip.write_all( - b"# Test Skill - -Name: test-skill -Version: 1.0.0 -Description: Test skill for validation", - ) - .unwrap(); - - // Add path traversal attempt - zip.start_file("../../../evil.txt", options).unwrap(); - zip.write_all(b"malicious content").unwrap(); - - zip.finish().unwrap(); - } - - #[test] - fn test_extract_zip_to_temp_rejects_path_traversal() { - let temp_dir = TempDir::new().unwrap(); - let extract_dir = temp_dir.path().join("extract"); - std::fs::create_dir_all(&extract_dir).unwrap(); - - let zip_path = temp_dir.path().join("malicious.zip"); - create_malicious_zip(&zip_path); - - let result = ValidationWorker::extract_zip_to_temp(&zip_path, &extract_dir); - - assert!(result.is_err()); - match result { - Err(ServiceError::Validation(msg)) => { - assert!( - msg.contains("path traversal") || msg.contains("Path traversal"), - "Error should mention path traversal: {}", - msg - ); - } - _ => panic!("Expected ServiceError::Validation for path traversal"), - } - - // Verify no file was created outside the extraction directory - let evil_path = extract_dir.join("../../../evil.txt"); - assert!(!evil_path.exists(), "Evil file should not exist"); - - // Safe file should exist if partial extraction occurred - let _safe_file = extract_dir.join("SKILL.md"); - // Note: Depending on implementation order, safe files might be created before the malicious one - } - - #[test] - fn test_extract_zip_to_temp_rejects_windows_traversal() { - let temp_dir = TempDir::new().unwrap(); - let extract_dir = temp_dir.path().join("extract"); - std::fs::create_dir_all(&extract_dir).unwrap(); - - let zip_path = temp_dir.path().join("windows-malicious.zip"); - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip = ZipWriter::new(file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - - zip.start_file("SKILL.md", options).unwrap(); - zip.write_all( - b"# Test - -Name: test -Version: 1.0.0 -Description: test", - ) - .unwrap(); - - zip.start_file("..\\..\\..\\evil.txt", options).unwrap(); - zip.write_all(b"malicious").unwrap(); - - zip.finish().unwrap(); - - let result = ValidationWorker::extract_zip_to_temp(&zip_path, &extract_dir); - - // On Unix, backslashes are treated as regular characters, so the path is literal - // On Windows, this would be a path traversal and should be rejected - // We accept either behavior since it's platform-specific - if cfg!(windows) { - assert!( - result.is_err(), - "Windows-style traversal should be rejected on Windows" - ); - } else { - // On Unix, the backslashes are literal characters - assert!(result.is_ok() || result.is_err()); - } - } -} diff --git a/crates/fastskill-core/src/core/registry_index.rs b/crates/fastskill-core/src/core/registry_index.rs index bf58cd79..dbe25720 100644 --- a/crates/fastskill-core/src/core/registry_index.rs +++ b/crates/fastskill-core/src/core/registry_index.rs @@ -5,10 +5,9 @@ use crate::security::validate_path_component; use chrono::{DateTime, Utc}; use semver; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; -use std::io::{Read, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -34,17 +33,11 @@ impl ScopedSkillName { } } -/// Create registry index structure -pub fn create_registry_structure(base_path: &Path) -> Result<(), ServiceError> { - fs::create_dir_all(base_path).map_err(ServiceError::Io)?; - Ok(()) -} - /// Get the index file path for a skill using org/package directory structure /// Format: {org}/{package} -/// skill_id must always be in "org/package" format (enforced at publish time) +/// skill_id must always be in "org/package" format (enforced at write time) pub fn get_skill_index_path(registry_path: &Path, skill_id: &str) -> Result { - // skill_id is always in org/package format (enforced at publish time) + // skill_id is always in org/package format // Organization is the authenticated user's username (lowercase, filesystem-safe) let parts: Vec<&str> = skill_id.split('/').collect(); @@ -65,22 +58,6 @@ pub fn get_skill_index_path(registry_path: &Path, skill_id: &str) -> Result Result { - Ok(crate::utils::append_suffix( - &get_skill_index_path(registry_path, skill_id)?, - "lock", - )) -} - /// Update registry index with a new skill version /// Appends newline-delimited JSON to single file per skill (crates.io format) pub fn update_skill_version( @@ -180,29 +157,6 @@ pub fn read_skill_versions( Ok(entries) } -/// Get version metadata from ZIP file -pub fn get_version_metadata( - skill_id: &str, - zip_path: &Path, - download_url: &str, -) -> Result { - // Calculate checksum - let cksum = calculate_file_checksum(zip_path)?; - - Ok(VersionMetadata { - name: skill_id.to_string(), - vers: String::new(), // Will be set by caller - deps: Vec::new(), - cksum, - features: HashMap::new(), - yanked: false, - links: None, - download_url: download_url.to_string(), - published_at: Utc::now().to_rfc3339(), - metadata: None, // Will be populated from skill package - }) -} - /// Version metadata entry #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionMetadata { @@ -315,106 +269,6 @@ where } } -/// Calculate SHA256 checksum of a file -fn calculate_file_checksum(file_path: &Path) -> Result { - let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?; - - let mut hasher = Sha256::new(); - let mut buffer = [0; 8192]; - - loop { - let bytes_read = file.read(&mut buffer).map_err(ServiceError::Io)?; - - if bytes_read == 0 { - break; - } - - hasher.update(&buffer[..bytes_read]); - } - - let hash = format!("sha256:{:x}", hasher.finalize()); - Ok(hash) -} - -/// Migrate old index format to new crates.io format -/// Old format: index/{first2}/{next2}/{skill-id}-{version}.json -/// New format: {first2}/{next2}/{skill-id} (or 1/{name}, 2/{name}, 3/{first}/{name}) -pub fn migrate_index_format( - old_registry_path: &Path, - new_registry_path: &Path, -) -> Result { - use walkdir::WalkDir; - - let mut migrated_count = 0; - - // Walk through old index structure - let index_dir = old_registry_path.join("index"); - if !index_dir.exists() { - return Ok(0); - } - - // Group files by skill ID - let mut skill_versions: std::collections::HashMap> = - std::collections::HashMap::new(); - - for entry in WalkDir::new(&index_dir) { - let entry = entry.map_err(|e| ServiceError::Io(e.into()))?; - let path = entry.path(); - - if path.is_file() { - // Parse old filename format: {skill-id}-{version}.json - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - if let Some((skill_id, version)) = parse_old_index_filename(file_name) { - // Read old format file - let content = fs::read_to_string(path).map_err(ServiceError::Io)?; - - if let Ok(entry) = serde_json::from_str::(&content) { - skill_versions - .entry(skill_id) - .or_default() - .push((version, entry)); - } - } - } - } - } - - // Write new format files - for (skill_id, versions) in skill_versions { - let new_index_path = get_skill_index_path(new_registry_path, &skill_id)?; - - // Create parent directories - if let Some(parent) = new_index_path.parent() { - fs::create_dir_all(parent).map_err(ServiceError::Io)?; - } - - // Write all versions to single file - let mut file = fs::File::create(&new_index_path).map_err(ServiceError::Io)?; - - for (_, entry) in versions { - let line = serde_json::to_string(&entry) - .map_err(|e| ServiceError::Custom(format!("Failed to serialize entry: {}", e)))?; - writeln!(file, "{}", line).map_err(ServiceError::Io)?; - } - - migrated_count += 1; - } - - Ok(migrated_count) -} - -/// Parse old index filename format: {skill-id}-{version}.json -fn parse_old_index_filename(filename: &str) -> Option<(String, String)> { - let without_ext = filename.strip_suffix(".json")?; - if let Some(last_dash) = without_ext.rfind('-') { - let skill_id = without_ext[..last_dash].to_string(); - let version = without_ext[last_dash + 1..].to_string(); - Some((skill_id, version)) - } else { - None - } -} - /// Extract scope from skill ID (format: scope/name) fn extract_scope(skill_id: &str) -> Option<(String, String)> { let parts: Vec<&str> = skill_id.split('/').collect(); diff --git a/crates/fastskill-core/src/core/service.rs b/crates/fastskill-core/src/core/service.rs index 3ee3e44f..3bc489dc 100644 --- a/crates/fastskill-core/src/core/service.rs +++ b/crates/fastskill-core/src/core/service.rs @@ -1,6 +1,5 @@ //! Main FastSkill service implementation -use crate::core::blob_storage::BlobStorageConfig; use crate::execution::ExecutionConfig; use std::path::PathBuf; use std::sync::Arc; @@ -38,18 +37,9 @@ pub struct ServiceConfig { /// Security configuration pub security: SecurityConfig, - /// Staging directory for registry publishing - pub staging_dir: Option, - - /// Registry blob storage configuration - pub registry_blob_storage: Option, - /// Registry index path pub registry_index_path: Option, - /// Registry blob base URL - pub registry_blob_base_url: Option, - /// HTTP server configuration pub http_server: Option, } @@ -63,10 +53,7 @@ impl Default for ServiceConfig { cache: CacheConfig::default(), embedding: None, security: SecurityConfig::default(), - staging_dir: None, - registry_blob_storage: None, registry_index_path: None, - registry_blob_base_url: None, http_server: None, } } diff --git a/crates/fastskill-core/src/http/handlers/mod.rs b/crates/fastskill-core/src/http/handlers/mod.rs index 385e7be4..548c1a7e 100644 --- a/crates/fastskill-core/src/http/handlers/mod.rs +++ b/crates/fastskill-core/src/http/handlers/mod.rs @@ -2,8 +2,6 @@ pub mod manifest; pub mod registry; -#[cfg(feature = "registry-publish")] -pub mod registry_publish; pub mod reindex; pub mod resolve; pub mod search; diff --git a/crates/fastskill-core/src/http/handlers/registry_publish.rs b/crates/fastskill-core/src/http/handlers/registry_publish.rs deleted file mode 100644 index 3732974a..00000000 --- a/crates/fastskill-core/src/http/handlers/registry_publish.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Registry publish endpoint handlers - -use crate::core::metadata::parse_yaml_frontmatter; -use crate::core::registry::staging::{StagingManager, StagingStatus}; -use crate::http::errors::{HttpError, HttpResult}; -use crate::http::handlers::AppState; -use crate::http::models::*; -use crate::security::validate_path_component; -use axum::{ - extract::{Multipart, Path, State}, - Json, -}; -use serde::Serialize; -use std::io::Read; -use zip::ZipArchive; - -/// Publish package response -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PublishResponse { - pub job_id: String, - pub status: String, - pub skill_id: String, - pub version: String, - pub message: String, -} - -/// Publish status response -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PublishStatusResponse { - pub job_id: String, - pub status: String, - pub skill_id: String, - pub version: String, - pub checksum: String, - pub uploaded_at: String, - pub uploaded_by: Option, - pub validation_errors: Vec, - pub message: Option, - pub published_to_blob_storage: Option, - pub blob_storage_url: Option, -} - -/// POST /api/v1/registry/publish - Publish a skill package -pub async fn publish_package( - State(state): State, - mut multipart: Multipart, -) -> HttpResult>> { - // Set fixed identity values for anonymous publishing - let uploaded_by = Some("anonymous".to_string()); - let user_scope = "anonymous".to_string(); - - // Get staging manager - let staging_dir = state - .service - .config() - .staging_dir - .clone() - .unwrap_or_else(|| std::path::PathBuf::from(".staging")); - - // Canonicalize staging_dir if it exists to prevent path traversal - let safe_staging_dir = if staging_dir.exists() { - staging_dir.canonicalize().map_err(|e| { - HttpError::InternalServerError(format!("Failed to resolve staging directory: {}", e)) - })? - } else { - staging_dir.clone() - }; - - tracing::info!("Using staging directory: {}", safe_staging_dir.display()); - tracing::info!("Staging directory exists: {}", safe_staging_dir.exists()); - - if !safe_staging_dir.exists() { - tracing::info!("Creating staging directory: {}", safe_staging_dir.display()); - std::fs::create_dir_all(&safe_staging_dir).map_err(|e| { - tracing::error!( - "Failed to create staging directory {}: {}", - safe_staging_dir.display(), - e - ); - HttpError::InternalServerError("Failed to create staging directory".to_string()) - })?; - } - - let staging_manager = StagingManager::new(safe_staging_dir.clone()); - tracing::info!( - "Initializing staging manager with directory: {}", - safe_staging_dir.display() - ); - staging_manager.initialize().map_err(|e| { - tracing::error!("Failed to initialize staging manager: {}", e); - HttpError::InternalServerError("Failed to initialize staging".to_string()) - })?; - - // Extract file from multipart - let mut package_data: Option> = None; - - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| HttpError::BadRequest(format!("Failed to read multipart field: {}", e)))? - { - let field_name = field.name().unwrap_or(""); - - if field_name == "file" || field_name == "package" { - let data = field - .bytes() - .await - .map_err(|e| HttpError::BadRequest(format!("Failed to read file data: {}", e)))?; - package_data = Some(data.to_vec()); - } - } - - let package_data = package_data - .ok_or_else(|| HttpError::BadRequest("No file provided in multipart form".to_string()))?; - - // Extract id and version from ZIP (id comes from skill-project.toml) - let (id, version) = extract_skill_metadata_from_zip(&package_data)?; - - // Validate id and version to prevent path traversal - validate_path_component(&id) - .map_err(|e| HttpError::BadRequest(format!("Invalid skill ID: {}", e)))?; - validate_path_component(&version) - .map_err(|e| HttpError::BadRequest(format!("Invalid version: {}", e)))?; - validate_path_component(&user_scope) - .map_err(|e| HttpError::BadRequest(format!("Invalid user scope: {}", e)))?; - - // Combine user scope with id: scope/id (for response) - // id from skill-project.toml should not contain slashes - let skill_id = format!("{}/{}", user_scope, id); - - // Store in staging (pass scope and id separately) - tracing::info!( - "Storing package: scope={}, id={}, version={}, package_size={} bytes", - user_scope, - id, - version, - package_data.len() - ); - - // Calculate the staging path that will be created - let staging_path = staging_manager.get_staging_path(&user_scope, &id, &version)?; - tracing::info!("Calculated staging path: {}", staging_path.display()); - - let (_, job_id) = staging_manager - .store_package( - &user_scope, - &id, - &version, - &package_data, - uploaded_by.as_deref(), - ) - .await - .map_err(|e| { - // Log detailed error information on server side for debugging - tracing::error!("Failed to store package {} v{}: {}", skill_id, version, e); - tracing::error!("Staging directory: {}", safe_staging_dir.display()); - tracing::error!("Staging directory exists: {}", safe_staging_dir.exists()); - tracing::error!("Calculated staging path: {}", staging_path.display()); - tracing::error!( - "Staging path parent exists: {}", - staging_path.parent().is_some_and(|p| p.exists()) - ); - if let Some(parent) = staging_path.parent() { - tracing::error!("Staging path parent: {}", parent.display()); - if let Err(check_err) = std::fs::read_dir(parent) { - tracing::error!("Cannot read staging path parent: {}", check_err); - } - } - // Return generic error message to client for security - HttpError::InternalServerError("Failed to store package".to_string()) - })?; - - // Validation worker will automatically pick up and process this package - - let response = PublishResponse { - job_id, - status: "pending".to_string(), - skill_id, - version, - message: "Package queued for validation".to_string(), - }; - - Ok(Json(ApiResponse::success(response))) -} - -/// GET /api/v1/registry/publish/status/:job_id - Get publish job status -pub async fn get_publish_status( - Path(job_id): Path, - State(state): State, -) -> HttpResult>> { - // Get staging manager - let staging_dir = state - .service - .config() - .staging_dir - .clone() - .unwrap_or_else(|| std::path::PathBuf::from(".staging")); - let staging_manager = StagingManager::new(staging_dir); - - // Load metadata - let metadata = staging_manager - .load_metadata(&job_id) - .map_err(|e| HttpError::InternalServerError(format!("Failed to load metadata: {}", e)))? - .ok_or_else(|| HttpError::NotFound(format!("Job {} not found", job_id)))?; - - // Check server configuration to determine publishing status - let config = state.service.config(); - let blob_storage_configured = config.registry_blob_storage.is_some(); - - // Determine if package was actually published (only if accepted and configs are present) - let published_to_blob_storage = - if metadata.status == StagingStatus::Accepted && blob_storage_configured { - Some(true) - } else if metadata.status == StagingStatus::Accepted { - Some(false) - } else { - None - }; - - // Try to get blob storage URL if published - let blob_storage_url = if published_to_blob_storage == Some(true) { - // Try to construct URL from config - if let Some(ref blob_config) = config.registry_blob_storage { - let package_path = staging_manager - .get_package_path(&job_id) - .map_err(|e| { - HttpError::InternalServerError(format!("Failed to get package path: {}", e)) - })? - .ok_or_else(|| { - HttpError::InternalServerError(format!("Package not found for job {}", job_id)) - })?; - - let package_filename = package_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - // Extract scope from the user who uploaded the package - // Scope is the part before '/' in uploaded_by, or the entire uploaded_by if no '/' - let scope = metadata - .uploaded_by - .as_ref() - .map(|u| u.split('/').next().unwrap_or(u).to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - let storage_path = format!("skills/{}/{}", scope, package_filename); - - if let Some(base_url) = &blob_config.base_url { - Some(format!( - "{}/{}", - base_url.trim_end_matches('/'), - storage_path - )) - } else { - config.registry_blob_base_url.as_ref().map(|blob_base_url| { - format!("{}/{}", blob_base_url.trim_end_matches('/'), storage_path) - }) - } - } else { - None - } - } else { - None - }; - - // Build descriptive message - let message = match metadata.status { - StagingStatus::Pending => Some("Package is pending validation".to_string()), - StagingStatus::Validating => Some("Package is being validated".to_string()), - StagingStatus::Accepted => { - if blob_storage_configured { - Some("Package has been accepted (published to blob storage)".to_string()) - } else { - Some("Package has been accepted (staging only)".to_string()) - } - } - StagingStatus::Rejected => Some("Package was rejected during validation".to_string()), - }; - - let response = PublishStatusResponse { - job_id: metadata.job_id.clone(), - status: metadata.status.as_str().to_string(), - skill_id: metadata.skill_id, - version: metadata.version, - checksum: metadata.checksum, - uploaded_at: metadata.uploaded_at.to_rfc3339(), - uploaded_by: metadata.uploaded_by, - validation_errors: metadata.validation_errors, - message, - published_to_blob_storage, - blob_storage_url, - }; - - Ok(Json(ApiResponse::success(response))) -} - -/// Extract skill_id and version from ZIP package -/// id is mandatory from skill-project.toml -/// version priority: skill-project.toml > SKILL.md frontmatter > default "1.0.0" -fn extract_skill_metadata_from_zip(zip_data: &[u8]) -> HttpResult<(String, String)> { - use crate::core::manifest::MetadataSection; - use std::io::Cursor; - - let cursor = Cursor::new(zip_data); - let mut archive = ZipArchive::new(cursor) - .map_err(|e| HttpError::BadRequest(format!("Invalid ZIP file: {}", e)))?; - - // Find and read SKILL.md and skill-project.toml - let mut skill_content = String::new(); - let mut skill_project_content: Option = None; - - for i in 0..archive.len() { - let file = archive - .by_index(i) - .map_err(|e| HttpError::BadRequest(format!("Failed to read ZIP entry: {}", e)))?; - - let file_name = file.name(); - - if file_name.ends_with("SKILL.md") { - let mut reader = std::io::BufReader::new(file); - reader - .read_to_string(&mut skill_content) - .map_err(|e| HttpError::BadRequest(format!("Failed to read SKILL.md: {}", e)))?; - } else if file_name.ends_with("skill-project.toml") { - let mut reader = std::io::BufReader::new(file); - let mut content = String::new(); - reader.read_to_string(&mut content).map_err(|e| { - HttpError::BadRequest(format!("Failed to read skill-project.toml: {}", e)) - })?; - skill_project_content = Some(content); - } - } - - // skill-project.toml is mandatory - let skill_project_str = skill_project_content.ok_or_else(|| { - HttpError::BadRequest("skill-project.toml is required but not found in package".to_string()) - })?; - - // Parse skill-project.toml to extract id (mandatory) and version - #[derive(serde::Deserialize)] - struct SkillProjectToml { - #[serde(default)] - metadata: Option, - } - - let skill_project: SkillProjectToml = toml::from_str(&skill_project_str) - .map_err(|e| HttpError::BadRequest(format!("Failed to parse skill-project.toml: {}", e)))?; - - let metadata = skill_project.metadata.ok_or_else(|| { - HttpError::BadRequest("skill-project.toml must have a [metadata] section".to_string()) - })?; - - // Extract id (mandatory) - let skill_id = metadata.id.ok_or_else(|| { - HttpError::BadRequest( - "skill-project.toml [metadata] section must have a non-empty 'id' field".to_string(), - ) - })?; - - // Extract version: priority skill-project.toml > SKILL.md frontmatter > default - let version = metadata - .version - .as_deref() - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .or_else(|| { - parse_yaml_frontmatter(&skill_content) - .ok() - .and_then(|f| f.version) - .filter(|v| !v.is_empty()) - }) - .unwrap_or_else(|| "1.0.0".to_string()); - - Ok((skill_id, version)) -} diff --git a/crates/fastskill-core/src/http/server.rs b/crates/fastskill-core/src/http/server.rs index b637df18..51008598 100644 --- a/crates/fastskill-core/src/http/server.rs +++ b/crates/fastskill-core/src/http/server.rs @@ -1,12 +1,6 @@ //! Axum HTTP server implementation -#[cfg(feature = "registry-publish")] -use crate::core::registry::{StagingManager, ValidationWorker, ValidationWorkerConfig}; use crate::core::service::FastSkillService; -#[cfg(feature = "registry-publish")] -use crate::core::service::ServiceConfig; -#[cfg(feature = "registry-publish")] -use crate::http::handlers::registry_publish; use crate::http::handlers::{ manifest, registry, reindex, resolve, search, skills, status, AppState, }; @@ -22,8 +16,6 @@ use cli_framework::api::{ApiServerBuilder, ApiVersion, ApiVersionName, DefaultVe use include_dir::{include_dir, Dir}; use std::env; use std::net::SocketAddr; -#[cfg(feature = "registry-publish")] -use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use tower_http::compression::CompressionLayer; @@ -60,86 +52,6 @@ async fn serve_embedded_static(req: Request) -> Result { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } -/// Validate blob storage configuration fields -#[cfg(feature = "registry-publish")] -fn validate_blob_storage_fields(blob_config: &crate::core::BlobStorageConfig) -> Vec<&'static str> { - let mut missing = Vec::new(); - - if blob_config.storage_type.is_empty() { - missing.push("storage_type"); - } - if blob_config.bucket.is_empty() { - missing.push("bucket"); - } - if blob_config.region.is_empty() { - missing.push("region"); - } - if blob_config.access_key.is_empty() { - missing.push("access_key"); - } - if blob_config.secret_key.is_empty() { - missing.push("secret_key"); - } - - missing -} - -/// Build error message for validation failures -#[cfg(feature = "registry-publish")] -fn build_validation_error_message( - missing: &[&str], - incomplete_fields: &[(&str, Vec<&str>)], -) -> String { - let mut error_msg = - String::from("Registry enabled but required configuration is missing or incomplete:\n"); - - if !missing.is_empty() { - error_msg.push_str(" Missing configuration:\n"); - for item in missing { - error_msg.push_str(&format!(" - {}\n", item)); - } - } - - if !incomplete_fields.is_empty() { - error_msg.push_str(" Incomplete configuration:\n"); - for (config_name, fields) in incomplete_fields { - error_msg.push_str(&format!(" {} is missing fields:\n", config_name)); - for field in fields { - error_msg.push_str(&format!(" - {}\n", field)); - } - } - } - - error_msg.push_str("\nS3 configuration is required for operational registry publishing."); - error_msg -} - -/// Validate registry configuration and return detailed error message if invalid -#[cfg(feature = "registry-publish")] -fn validate_registry_config(config: &ServiceConfig) -> Result<(), String> { - let mut missing = Vec::new(); - let mut incomplete_fields = Vec::new(); - - // Check registry_blob_storage - match &config.registry_blob_storage { - None => { - missing.push("registry_blob_storage"); - } - Some(blob_config) => { - let blob_missing = validate_blob_storage_fields(blob_config); - if !blob_missing.is_empty() { - incomplete_fields.push(("registry_blob_storage", blob_missing)); - } - } - } - - if missing.is_empty() && incomplete_fields.is_empty() { - return Ok(()); - } - - Err(build_validation_error_message(&missing, &incomplete_fields)) -} - /// Get allowed methods for CORS fn get_allowed_methods() -> [Method; 5] { [ @@ -340,7 +252,7 @@ impl FastSkillServer { /// Registry API routes under /api/v1/ (prefix stripped, axum 0.8 params) fn create_registry_api_routes_v1() -> Router { - let router = Router::new() + Router::new() .route("/registry/index/skills", get(registry::list_index_skills)) .route("/registry/sources", get(registry::list_sources)) .route("/registry/skills", get(registry::list_all_skills)) @@ -352,17 +264,7 @@ impl FastSkillServer { "/registry/sources/{name}/marketplace", get(registry::get_marketplace), ) - .route("/registry/refresh", post(registry::refresh_sources)); - - #[cfg(feature = "registry-publish")] - let router = router - .route("/registry/publish", post(registry_publish::publish_package)) - .route( - "/registry/publish/status/{job_id}", - get(registry_publish::get_publish_status), - ); - - router + .route("/registry/refresh", post(registry::refresh_sources)) } /// Raw index routes for mount("/index") — paths relative to the mount point @@ -452,33 +354,6 @@ impl FastSkillServer { .health_version(env!("CARGO_PKG_VERSION")) .build(); - #[cfg(feature = "registry-publish")] - let shutdown = server.shutdown_token(); - - // Start validation worker bound to the server shutdown token. - #[cfg(feature = "registry-publish")] - { - let config = self.service.config(); - if validate_registry_config(config).is_ok() { - let staging_dir = config - .staging_dir - .clone() - .unwrap_or_else(|| PathBuf::from(".staging")); - - let staging_manager = StagingManager::new(staging_dir); - if staging_manager.initialize().is_ok() { - let worker_config = ValidationWorkerConfig { - poll_interval_secs: 5, - blob_storage_config: config.registry_blob_storage.clone(), - registry_index_path: config.registry_index_path.clone(), - blob_base_url: config.registry_blob_base_url.clone(), - }; - let worker = ValidationWorker::new(staging_manager, worker_config); - worker.start_with_shutdown(shutdown.clone()); - } - } - } - info!("Starting FastSkill HTTP server on {}", self.addr); let addr_str = self.addr.to_string(); diff --git a/crates/fastskill-core/tests/index_manager_test.rs b/crates/fastskill-core/tests/index_manager_test.rs deleted file mode 100644 index bf8b281e..00000000 --- a/crates/fastskill-core/tests/index_manager_test.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Unit tests for IndexManager - -#![allow(clippy::all, clippy::unwrap_used, clippy::expect_used)] - -use fastskill_core::core::registry::index_manager::IndexManager; -use fastskill_core::core::registry_index::{ScopedSkillName, VersionEntry}; -use fastskill_core::core::service::ServiceError; -use std::collections::HashMap; -use tempfile::TempDir; - -#[test] -fn test_scoped_name_normalization() { - // Test @org/package format - assert_eq!( - ScopedSkillName::normalize("@acme/web-scraper"), - "acme/web-scraper" - ); - - // Test org:package format - assert_eq!( - ScopedSkillName::normalize("acme:web-scraper"), - "acme/web-scraper" - ); - - // Test org/package format (already normalized) - assert_eq!( - ScopedSkillName::normalize("acme/web-scraper"), - "acme/web-scraper" - ); - - // Test unscoped name - assert_eq!(ScopedSkillName::normalize("web-scraper"), "web-scraper"); - - // Test with whitespace - assert_eq!( - ScopedSkillName::normalize(" @acme/web-scraper "), - "acme/web-scraper" - ); -} - -#[test] -fn test_index_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let _manager = IndexManager::new(registry_path.clone()); - - // Manager should be created successfully - // We can't test much more without implementing the full atomic_update - // but we can verify the struct is created - assert!(true); // Placeholder - manager creation succeeded -} - -#[test] -fn test_duplicate_version_rejection() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - // Create a test entry - let entry = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // First publish should succeed - let result1 = manager.atomic_update("testorg/test-skill", "1.0.0", &entry); - - // Second publish with same version should fail with duplicate error - let result2 = manager.atomic_update("testorg/test-skill", "1.0.0", &entry); - - // First should succeed, second should fail - if result1.is_ok() { - assert!(result2.is_err()); - if let Err(ServiceError::Custom(msg)) = result2 { - assert!(msg.contains("already exists") || msg.contains("duplicate")); - } - } -} - -#[test] -fn test_concurrent_writes_same_skill() { - // Test that two concurrent writes to same skill file are serialized correctly - use std::sync::Arc; - use std::thread; - - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry1 = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let entry2 = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "2.0.0".to_string(), - deps: vec![], - cksum: "def456".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-2.0.0.zip".to_string(), - published_at: "2024-01-02T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let manager1 = manager.clone(); - let manager2 = manager.clone(); - - let handle1 = - thread::spawn(move || manager1.atomic_update("testorg/test-skill", "1.0.0", &entry1)); - - let handle2 = - thread::spawn(move || manager2.atomic_update("testorg/test-skill", "2.0.0", &entry2)); - - let result1 = handle1.join().unwrap(); - let result2 = handle2.join().unwrap(); - - // Both should succeed (serialized by file lock) - assert!(result1.is_ok() || result1.is_err()); - assert!(result2.is_ok() || result2.is_err()); -} - -#[test] -fn test_concurrent_writes_different_skills() { - // Test that concurrent writes to different skill files don't block each other - use std::sync::Arc; - use std::thread; - - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry1 = VersionEntry { - name: "testorg/skill-a".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-a-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let entry2 = VersionEntry { - name: "testorg/skill-b".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "def456".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-b-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let manager1 = manager.clone(); - let manager2 = manager.clone(); - - let handle1 = - thread::spawn(move || manager1.atomic_update("testorg/skill-a", "1.0.0", &entry1)); - - let handle2 = - thread::spawn(move || manager2.atomic_update("testorg/skill-b", "1.0.0", &entry2)); - - let result1 = handle1.join().unwrap(); - let result2 = handle2.join().unwrap(); - - // Both should succeed (different files, no blocking) - assert!(result1.is_ok() || result1.is_err()); - assert!(result2.is_ok() || result2.is_err()); -} diff --git a/crates/fastskill-core/tests/registry_atomic_test.rs b/crates/fastskill-core/tests/registry_atomic_test.rs deleted file mode 100644 index e7fa099a..00000000 --- a/crates/fastskill-core/tests/registry_atomic_test.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! Integration tests for atomic registry operations and concurrent publishing - -#![allow(clippy::all, clippy::unwrap_used, clippy::expect_used)] - -use fastskill_core::core::registry::index_manager::IndexManager; -use fastskill_core::core::registry_index::{read_skill_versions, VersionEntry}; -use std::collections::HashMap; -use std::sync::Arc; -use tempfile::TempDir; - -#[tokio::test] -async fn test_concurrent_writes_same_skill() { - // Test that two concurrent writes to the same skill file are serialized correctly - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry1 = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let entry2 = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "2.0.0".to_string(), - deps: vec![], - cksum: "def456".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-2.0.0.zip".to_string(), - published_at: "2024-01-02T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // Spawn two concurrent writes to the same skill - let manager1 = manager.clone(); - let manager2 = manager.clone(); - - let handle1 = tokio::task::spawn_blocking(move || { - manager1.atomic_update("testorg/test-skill", "1.0.0", &entry1) - }); - - let handle2 = tokio::task::spawn_blocking(move || { - manager2.atomic_update("testorg/test-skill", "2.0.0", &entry2) - }); - - // Both should succeed (serialized by file lock) - let result1 = handle1.await.unwrap(); - let result2 = handle2.await.unwrap(); - - // Both operations should succeed - assert!(result1.is_ok() || result1.is_err()); // Accept either for now (implementation may not be complete) - assert!(result2.is_ok() || result2.is_err()); - - // If both succeeded, verify both versions are in the index - if result1.is_ok() && result2.is_ok() { - let entries = read_skill_versions(®istry_path, "testorg/test-skill").unwrap(); - assert!(entries.iter().any(|e| e.vers == "1.0.0")); - assert!(entries.iter().any(|e| e.vers == "2.0.0")); - } -} - -#[tokio::test] -async fn test_concurrent_writes_different_skills() { - // Test that concurrent writes to different skill files don't block each other - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry1 = VersionEntry { - name: "testorg/skill-a".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-a-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let entry2 = VersionEntry { - name: "testorg/skill-b".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "def456".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-b-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // Spawn two concurrent writes to different skills - let manager1 = manager.clone(); - let manager2 = manager.clone(); - - let handle1 = tokio::task::spawn_blocking(move || { - manager1.atomic_update("testorg/skill-a", "1.0.0", &entry1) - }); - - let handle2 = tokio::task::spawn_blocking(move || { - manager2.atomic_update("testorg/skill-b", "1.0.0", &entry2) - }); - - // Both should succeed quickly (no blocking since different files) - let result1 = handle1.await.unwrap(); - let result2 = handle2.await.unwrap(); - - // Both operations should succeed - assert!(result1.is_ok() || result1.is_err()); - assert!(result2.is_ok() || result2.is_err()); -} - -#[tokio::test] -async fn test_concurrent_publish_operations() { - // Test that 10 concurrent publish operations all succeed and all versions appear - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let mut handles = Vec::new(); - - // Spawn 10 concurrent publish operations - for i in 0..10 { - let manager_clone = manager.clone(); - let entry = VersionEntry { - name: "testorg/concurrent-skill".to_string(), - vers: format!("1.0.{}", i), - deps: vec![], - cksum: format!("checksum{}", i), - features: HashMap::new(), - yanked: false, - links: None, - download_url: format!("https://example.com/skill-1.0.{}.zip", i), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let handle = tokio::task::spawn_blocking(move || { - manager_clone.atomic_update("testorg/concurrent-skill", &format!("1.0.{}", i), &entry) - }); - - handles.push(handle); - } - - // Wait for all operations to complete - let results: Vec<_> = futures::future::join_all(handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - // Count successful operations - let success_count = results.iter().filter(|r| r.is_ok()).count(); - - // At least some operations should succeed (depending on implementation completeness) - // Once fully implemented, all 10 should succeed - assert!(success_count > 0 || success_count == 0); // Placeholder assertion - always true but avoids useless comparison warning -} - -#[tokio::test] -async fn test_lock_timeout_handling() { - // Test that lock timeout (30 seconds) returns appropriate error message - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry = VersionEntry { - name: "testorg/timeout-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // This test would require holding a lock for >30 seconds, which is complex to test - // For now, we verify the timeout mechanism exists in the code - // Full timeout test would require mocking or actual long-running lock - - // Just verify the method can be called - let result = manager.atomic_update("testorg/timeout-skill", "1.0.0", &entry); - assert!(result.is_ok() || result.is_err()); -} - -#[tokio::test] -async fn test_readers_dont_block_during_writes() { - // Test that index file reads succeed while write operations are in progress (FR-012) - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let entry = VersionEntry { - name: "testorg/read-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // Spawn a write operation - let manager_write = manager.clone(); - let write_handle = tokio::task::spawn_blocking(move || { - manager_write.atomic_update("testorg/read-skill", "1.0.0", &entry) - }); - - // While write is in progress, try to read - // Advisory locks should allow reads - let read_result = read_skill_versions(®istry_path, "testorg/read-skill"); - - // Read should succeed (advisory locks don't block readers) - assert!(read_result.is_ok() || read_result.is_err()); // Accept either for now - - // Wait for write to complete - let _write_result = write_handle.await; -} - -#[tokio::test] -// Perf SLA (SC-003, p95 < 100ms): timing/load-sensitive and flaky under parallel -// nextest. Run manually with `--ignored`, not in the default CI gate. -#[ignore] -async fn test_index_update_performance() { - // Test that 95% of index updates complete in under 100ms (SC-003) - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let mut latencies = Vec::new(); - - // Perform 100 index updates and measure latency - for i in 0..100 { - let entry = VersionEntry { - name: "testorg/perf-skill".to_string(), - vers: format!("1.0.{}", i), - deps: vec![], - cksum: format!("checksum{}", i), - features: HashMap::new(), - yanked: false, - links: None, - download_url: format!("https://example.com/skill-1.0.{}.zip", i), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let start = std::time::Instant::now(); - let manager_clone = manager.clone(); - let result = tokio::task::spawn_blocking(move || { - manager_clone.atomic_update("testorg/perf-skill", &format!("1.0.{}", i), &entry) - }) - .await - .unwrap(); - let elapsed = start.elapsed(); - - if result.is_ok() { - latencies.push(elapsed.as_millis() as u64); - } - } - - if latencies.len() >= 95 { - // Sort and get 95th percentile - latencies.sort(); - let p95 = latencies[(latencies.len() * 95) / 100]; - - // 95% should be under 100ms - assert!( - p95 < 100, - "95th percentile latency {}ms exceeds 100ms threshold", - p95 - ); - } -} - -#[tokio::test] -async fn test_sequential_publish_operations() { - // Test that 1000 sequential publish operations complete without data loss (SC-005) - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - let mut success_count = 0; - - // Perform 1000 sequential operations (reduced from 1000 for test speed) - for i in 0..100 { - let entry = VersionEntry { - name: "testorg/sequential-skill".to_string(), - vers: format!("1.0.{}", i), - deps: vec![], - cksum: format!("checksum{}", i), - features: HashMap::new(), - yanked: false, - links: None, - download_url: format!("https://example.com/skill-1.0.{}.zip", i), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let manager_clone = manager.clone(); - let result = tokio::task::spawn_blocking(move || { - manager_clone.atomic_update("testorg/sequential-skill", &format!("1.0.{}", i), &entry) - }) - .await - .unwrap(); - - if result.is_ok() { - success_count += 1; - } - } - - // Verify all entries are present (no data loss) - let entries = read_skill_versions(®istry_path, "testorg/sequential-skill").unwrap(); - assert_eq!( - entries.len(), - success_count, - "Data loss detected: expected {} entries, found {}", - success_count, - entries.len() - ); -} - -#[tokio::test] -async fn test_concurrent_read_write_success_rate() { - // Test that 99.9% read success rate during concurrent write operations (SC-007) - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = Arc::new(IndexManager::new(registry_path.clone())); - - // First, create an initial entry - let initial_entry = VersionEntry { - name: "testorg/read-write-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let manager_init = manager.clone(); - let _ = tokio::task::spawn_blocking(move || { - manager_init.atomic_update("testorg/read-write-skill", "1.0.0", &initial_entry) - }) - .await; - - // Spawn concurrent writes and reads - let mut read_handles = Vec::new(); - let mut write_handles = Vec::new(); - - // Spawn 10 write operations - for i in 1..11 { - let manager_write = manager.clone(); - let entry = VersionEntry { - name: "testorg/read-write-skill".to_string(), - vers: format!("1.0.{}", i), - deps: vec![], - cksum: format!("checksum{}", i), - features: HashMap::new(), - yanked: false, - links: None, - download_url: format!("https://example.com/skill-1.0.{}.zip", i), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - let handle = tokio::task::spawn_blocking(move || { - manager_write.atomic_update("testorg/read-write-skill", &format!("1.0.{}", i), &entry) - }); - write_handles.push(handle); - } - - // Spawn 100 read operations (during writes) - for _ in 0..100 { - let registry_path_read = registry_path.clone(); - let handle = tokio::task::spawn_blocking(move || { - read_skill_versions(®istry_path_read, "testorg/read-write-skill") - }); - read_handles.push(handle); - } - - // Wait for all operations - let read_results: Vec<_> = futures::future::join_all(read_handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - let _write_results: Vec<_> = futures::future::join_all(write_handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - // Count successful reads - let successful_reads = read_results.iter().filter(|r| r.is_ok()).count(); - let success_rate = (successful_reads as f64 / read_results.len() as f64) * 100.0; - - // 99.9% success rate required - assert!( - success_rate >= 99.9, - "Read success rate {}% is below 99.9% threshold", - success_rate - ); -} diff --git a/crates/fastskill-core/tests/registry_index_test.rs b/crates/fastskill-core/tests/registry_index_test.rs index 9e64e1a1..b969e8a8 100644 --- a/crates/fastskill-core/tests/registry_index_test.rs +++ b/crates/fastskill-core/tests/registry_index_test.rs @@ -7,7 +7,6 @@ clippy::needless_borrows_for_generic_args )] -use fastskill_core::core::registry::index_manager::IndexManager; use fastskill_core::core::registry_index::{read_skill_versions, ScopedSkillName, VersionEntry}; use std::collections::HashMap; use tempfile::TempDir; @@ -19,36 +18,6 @@ fn test_scoped_name_integration() { assert_eq!(normalized, "acme/web-scraper"); } -#[test] -fn test_index_manager_basic_operations() { - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - // Create a test entry - let entry = VersionEntry { - name: "testorg/test-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/test-skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: Some("@acme/test-skill".to_string()), - }; - - // Test that atomic_update can be called - // The actual implementation will be tested once it's complete - let result = manager.atomic_update("@acme/test-skill", "1.0.0", &entry); - - // For now, just verify the method exists and can be called - // Full functionality will be tested once implementation is complete - assert!(result.is_err() || result.is_ok()); -} - #[tokio::test] async fn test_index_served_from_filesystem() { // Test that index files can be read directly from filesystem without Git operations @@ -93,38 +62,6 @@ async fn test_index_served_from_filesystem() { assert_eq!(entries[0].vers, "1.0.0"); } -#[tokio::test] -async fn test_publish_no_git_operations() { - // Test that publish operation (via IndexManager) completes without Git commit or push - let temp_dir = TempDir::new().unwrap(); - let registry_path = temp_dir.path().to_path_buf(); - let manager = IndexManager::new(registry_path.clone()); - - let entry = VersionEntry { - name: "testorg/no-git-skill".to_string(), - vers: "1.0.0".to_string(), - deps: vec![], - cksum: "abc123".to_string(), - features: HashMap::new(), - yanked: false, - links: None, - download_url: "https://example.com/skill-1.0.0.zip".to_string(), - published_at: "2024-01-01T00:00:00Z".to_string(), - metadata: None, - scoped_name: None, - }; - - // Publish using IndexManager (no Git operations) - let result = manager.atomic_update("testorg/no-git-skill", "1.0.0", &entry); - - // Should succeed without any Git operations - // Verify no .git directory was created - assert!(!registry_path.join(".git").exists()); - - // Result may succeed or fail depending on implementation completeness - assert!(result.is_ok() || result.is_err()); -} - #[tokio::test] async fn test_server_restart_no_git_clone() { // Test that index can be served from filesystem without cloning Git repository diff --git a/specs/001-remove-publish.md b/specs/001-remove-publish.md new file mode 100644 index 00000000..4e635934 --- /dev/null +++ b/specs/001-remove-publish.md @@ -0,0 +1,688 @@ +# Spec 001 — Remove Publish Functionality + +**Status:** PROPOSED +**Branch convention:** `chore/remove-publish` + +--- + +## Background + +The `registry-publish` Cargo feature gate (introduced in PR #194) controls a self-hosted skill registry pipeline: a client uploads a skill package, a server-side validation worker checks it, the index is updated on disk, and the artifact is stored in S3 or local blob storage. The feature is **off by default** and has never appeared in a release binary. + +Decision: remove the publish subsystem entirely rather than continue maintaining it behind a flag. + +**Reasons:** +- The gofastskill marketplace uses a platform-managed deploy workflow (CRD + Operator, spec 003) — not a self-hosted FastSkill server write path. +- `aws-sdk-s3` + `aws-config` add meaningful compile-time and binary weight with no current payoff. +- Dead feature flags compound maintenance: dual code paths, dual CI runs, documentation, onboarding confusion. + +**Also removed in this spec:** `fastskill package` (creates a distributable zip). Its only material use case was feeding into publish; without publish it has no place in the product. + +--- + +## Before / After + +### Before + +``` +fastskill serve → HTTP server including: + - skill CRUD + search + MCP (core purpose) + - POST /registry/publish ← GOES + - GET /registry/publish/status/{id} ← GOES + +fastskill auth login ← GOES (exists only to acquire tokens for publish) +fastskill auth logout ← GOES +fastskill auth whoami ← GOES +fastskill publish ← GOES +fastskill package ← GOES (packaging step before publish) + +Cargo features: + fastskill-core: registry-publish = ["aws-sdk-s3", "aws-config"] + fastskill-cli: registry-publish = ["fastskill-core/registry-publish", ...] + +Dependencies pulled by publish: + aws-sdk-s3, aws-config (optional, both crates) + multer (fastskill-core, unconditional) + base64 (fastskill-cli, unconditional — JWT parsing in auth_config.rs) + zip (fastskill-cli, unconditional — packaging.rs + publish.rs) +``` + +### After + +``` +fastskill serve → HTTP server: + - skill CRUD + search + MCP (unchanged) + - GET /registry/index/skills (unchanged) + - GET /registry/sources (unchanged) + - GET /registry/skills (unchanged) + - GET /registry/refresh (unchanged) + - read-only index file serving (unchanged) + ← no publish or package endpoints + +fastskill auth ← command group gone +fastskill publish ← gone +fastskill package ← gone + +Cargo features: + fastskill-core: (registry-publish feature removed) + fastskill-cli: (registry-publish feature removed) + +Dependencies removed: + aws-sdk-s3, aws-config (both crates) + multer (fastskill-core) + base64 (fastskill-cli) + zip (fastskill-cli only; fastskill-core keeps it for storage/zip.rs) +``` + +**Commands that survive unchanged:** +`add`, `analyze`, `doctor`, `eval`, `init`, `install`, `list`, `marketplace`, `read`, `reindex`, `registry`, `repos`, `search`, `serve`, `skillopt`, `update` + +--- + +## What is NOT removed + +| Item | Why kept | +|---|---| +| `crates/fastskill-core/src/core/registry/client.rs` | Downloads skills from a registry for `install` / `add` | +| `crates/fastskill-core/src/core/registry/auth.rs` | `Auth` trait + GitHub PAT / SSH key / API key — used by `registry/client.rs` and `repository/client.rs` for authenticated downloads | +| `crates/fastskill-core/src/core/registry/config.rs` | Registry URL config for `fastskill registry add/remove/list` | +| `crates/fastskill-core/src/core/registry_index.rs` | Read path types: `scan_registry_index`, `SkillSummary`, `ListSkillsOptions`, `VersionEntry` (on-disk format), `read_skill_versions`, `IndexMetadata` — all used by `GET /registry/index/skills` and CLI search. Some dead write-path symbols are pruned (see below). | +| `crates/fastskill-core/src/http/handlers/registry.rs` | Serves read-only registry browse/search endpoints | +| `crates/fastskill-core/src/storage/zip.rs` + `ZipHandler` | Used by `add/sources.rs` to unpack downloaded skill zips | +| `crates/fastskill-core/src/events/event_bus.rs` | Fires local skill-store events (`SkillRegistered`, `HotReloadEnabled`, etc.) — unrelated to publish | + +--- + +## Complete file inventory + +### Files to DELETE + +#### fastskill-core — server-side publish pipeline (5 files) + +| File | Lines | What it does | +|---|---|---| +| `crates/fastskill-core/src/core/blob_storage.rs` | 357 | `BlobStorage` trait + `LocalBlobStorage` + S3 impl. Only consumed by the publish server path and `ServiceConfig`. | +| `crates/fastskill-core/src/core/registry/index_manager.rs` | 523 | `IndexManager::atomic_update` — writes `VersionEntry` NDJSON to the on-disk registry index during publish. | +| `crates/fastskill-core/src/core/registry/staging.rs` | 359 | `StagingManager` — holds uploaded packages in a temp directory pending validation. | +| `crates/fastskill-core/src/core/registry/validation_worker.rs` | 649 | `ValidationWorker` — async background task that unzips and validates staged packages. | +| `crates/fastskill-core/src/http/handlers/registry_publish.rs` | 377 | Axum handlers for `POST /registry/publish` and `GET /registry/publish/status/{job_id}`. | + +#### fastskill-core — publish tests (2 files) + +| File | What it tests | +|---|---| +| `crates/fastskill-core/tests/index_manager_test.rs` | `IndexManager` write path | +| `crates/fastskill-core/tests/registry_atomic_test.rs` | Concurrent / sequential `IndexManager::atomic_update` | + +#### fastskill-cli — client-side publish + package pipeline (5 files) + +| File | Lines | What it does | +|---|---|---| +| `crates/fastskill-cli/src/commands/publish.rs` | 837 | `fastskill publish` — packages skill, authenticates, POSTs to `/registry/publish`, polls status. | +| `crates/fastskill-cli/src/commands/package.rs` | 732 | `fastskill package` — creates a distributable `.zip` artifact locally. Primary use case was feeding into publish. | +| `crates/fastskill-cli/src/commands/auth.rs` | 303 | `fastskill auth login/logout/whoami` — manages per-registry JWT tokens. Exists solely to support publish. | +| `crates/fastskill-cli/src/auth_config.rs` | 386 | Reads/writes `~/.config/fastskill/auth.toml`. Only callers: `publish.rs` and `auth.rs`. | +| `crates/fastskill-cli/src/utils/api_client.rs` | ~240 | `ApiClient::publish_package`, `get_publish_status`, `PublishApiResponse`, `PublishStatusApiResponse`. Entire file is gated via `#[cfg(feature = "registry-publish")]` in `utils.rs`. | + +#### fastskill-core — packaging (1 file) + +| File | Lines | What it does | +|---|---|---| +| `crates/fastskill-core/src/core/packaging.rs` | 369 | `package_skill`, `package_skill_with_id`, `calculate_checksum`, `create_build_metadata`. Only called from `commands/package.rs` (being deleted) and `commands/publish.rs` (being deleted). | + +#### Root integration tests and fixtures + +| Path | Reason | +|---|---| +| `tests/integration_registry_publish_test.rs` | End-to-end publish flow test | +| `tests/fixtures/test-skill-registry-publish/` | Fixture directory (4 files) used only by the above test | + +#### CLI e2e tests and snapshots + +| Path | +|---| +| `tests/cli/auth_e2e_tests.rs` | +| `tests/cli/package_tests.rs` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_custom_role.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_credentials.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_port.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_success.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_unreachable.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_nonexistent.snap` | +| `tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_success.snap` | + +--- + +### Files to MODIFY (15 files) + +--- + +#### `crates/fastskill-core/Cargo.toml` + +Remove: +```toml +# line 52 — only used in registry_publish.rs handler +multer.workspace = true + +# lines 95–96 +aws-sdk-s3 = { workspace = true, optional = true } +aws-config = { workspace = true, optional = true } + +# line 113 +registry-publish = ["aws-sdk-s3", "aws-config"] +``` + +Keep `zip.workspace = true` — still required by `storage/zip.rs` for unpacking downloaded skills. + +--- + +#### `crates/fastskill-core/src/core/registry.rs` + +Remove three submodule declarations and two re-export lines: + +```rust +// REMOVE: +pub mod index_manager; +pub mod staging; +pub mod validation_worker; + +// REMOVE: +pub use staging::{StagingManager, StagingMetadata, StagingStatus}; +pub use validation_worker::{ValidationWorker, ValidationWorkerConfig}; +``` + +Keep `pub mod auth`, `pub mod client`, `pub mod config` and their `pub use` lines unchanged. + +--- + +#### `crates/fastskill-core/src/core/mod.rs` + +Remove `blob_storage` and `packaging` module declarations and their re-export blocks: + +```rust +// REMOVE line 4: +pub mod blob_storage; + +// REMOVE line 15: +pub mod packaging; + +// REMOVE line 36: +pub use blob_storage::{create_blob_storage, BlobStorage, BlobStorageConfig, LocalBlobStorage}; + +// REMOVE lines 70–72: +pub use packaging::{ + calculate_checksum, create_build_metadata, package_skill, package_skill_with_id, +}; +``` + +Also trim the `registry_index` re-export block (currently lines 85–88) to remove the six symbols that are only used by the files being deleted: + +```rust +// BEFORE: +pub use registry_index::{ + create_registry_structure, get_skill_index_path, get_version_metadata, migrate_index_format, + read_skill_versions, IndexMetadata, VersionEntry, VersionMetadata, +}; + +// AFTER (keep only what live code references): +pub use registry_index::{read_skill_versions, IndexMetadata, VersionEntry, VersionMetadata}; +``` + +Symbols removed from re-export: `create_registry_structure`, `get_skill_index_path`, `get_version_metadata`, `migrate_index_format`. Note `get_skill_index_lock_path` and `ScopedSkillName` are not in this re-export block but must also be pruned from `registry_index.rs` itself (see below). + +--- + +#### `crates/fastskill-core/src/core/registry_index.rs` + +Remove six publish-write-path symbols. Their only callers were `index_manager.rs` and `validation_worker.rs` (both being deleted): + +| Symbol | Approx. line | Remove | +|---|---|---| +| `ScopedSkillName` struct + impl | 16–37 | yes | +| `create_registry_structure` fn | 38–45 | yes | +| `get_skill_index_path` fn | 46–70 | yes | +| `get_skill_index_lock_path` fn | 72–83 | yes | +| `get_version_metadata` fn | 184–222 | yes | +| `migrate_index_format` fn | 342–end area | yes | + +Keep everything else: `VersionEntry`, `VersionMetadata`, `IndexMetadata`, `read_skill_versions`, `scan_registry_index`, `SkillSummary`, `ListSkillsOptions`, and any helpers they call internally. + +--- + +#### `crates/fastskill-core/src/core/service.rs` + +Remove the `BlobStorageConfig` import and two fields from `ServiceConfig`: + +```rust +// REMOVE (line 3): +use crate::core::blob_storage::BlobStorageConfig; + +// REMOVE from struct body (lines 42, 45): +/// Staging directory for registry publishing +pub staging_dir: Option, +pub registry_blob_storage: Option, + +// REMOVE from Default impl (lines 66–67): +staging_dir: None, +registry_blob_storage: None, +``` + +--- + +#### `crates/fastskill-core/src/http/handlers/mod.rs` + +Remove two lines: + +```rust +// REMOVE: +#[cfg(feature = "registry-publish")] +pub mod registry_publish; +``` + +--- + +#### `crates/fastskill-core/src/http/server.rs` + +Remove every `#[cfg(feature = "registry-publish")]` block. Confirmed occurrences at lines 3–9, 25, 64, 88, 113, 118, 357–362, 455, 459. Specifically: + +- The `use crate::http::handlers::registry_publish;` import. +- Any `AppState` fields holding `StagingManager`, `ValidationWorker`, or `Arc`. +- S3 / staging startup validation (`error_msg.push_str("S3 configuration is required...")`). +- The two route registrations in `create_registry_api_routes_v1`: + ```rust + .route("/registry/publish", post(registry_publish::publish_package)) + .route("/registry/publish/status/{job_id}", get(registry_publish::get_publish_status)) + ``` + +--- + +#### `crates/fastskill-cli/Cargo.toml` + +Remove: + +```toml +# line 71 — only used in auth_config.rs for JWT decoding +base64.workspace = true + +# lines 72-ish — only used in commands/package.rs and commands/publish.rs +zip.workspace = true + +# lines 92–93 +aws-sdk-s3 = { workspace = true, optional = true } +aws-config = { workspace = true, optional = true } + +# line 103 +registry-publish = ["fastskill-core/registry-publish", "dep:aws-sdk-s3", "dep:aws-config"] +``` + +Keep `sha2.workspace = true` — used in `commands/reindex.rs`. Keep `vendored-openssl` — still required for musl cross-compilation. + +--- + +#### `crates/fastskill-cli/src/commands/mod.rs` + +Remove three lines: + +```rust +// REMOVE: +pub mod auth; + +// REMOVE: +pub mod package; + +// REMOVE: +#[cfg(feature = "registry-publish")] +pub mod publish; +``` + +--- + +#### `crates/fastskill-cli/src/mod.rs` + +Remove: + +```rust +// REMOVE line 4: +pub mod auth_config; +``` + +--- + +#### `crates/fastskill-cli/src/utils.rs` + +Remove: + +```rust +// REMOVE lines 3–4: +#[cfg(feature = "registry-publish")] +pub mod api_client; +``` + +--- + +#### `crates/fastskill-cli/src/main.rs` + +Five changes: + +1. Remove `mod auth_config;` (line 14). + +2. Remove `auth` and `package` from the `use commands::{...}` import (line 67): + ```rust + // BEFORE: + use commands::{add, analyze, auth, doctor, eval, init, install, list, marketplace, package, read, reindex, ...}; + // AFTER: + use commands::{add, analyze, doctor, eval, init, install, list, marketplace, read, reindex, ...}; + ``` + +3. Remove the feature-gated publish import (lines 64–65): + ```rust + // REMOVE: + #[cfg(feature = "registry-publish")] + use commands::publish; + ``` + +4. Remove the entire `auth` command registration block (~lines 368–401) that registers `path!["auth"]`, `path!["auth", "login"]`, `path!["auth", "logout"]`, `path!["auth", "whoami"]`. + +5. Remove the `package` command registration block (~lines 205–212): + ```rust + // REMOVE: + builder.register( + path!["package"], + |_ctx, args: package::PackageArgs| async move { + package::execute_package(args) ... + }, + )? + ``` + +6. Remove the `#[cfg(feature = "registry-publish")]` publish registration block (~lines 215–220). + +--- + +#### `crates/fastskill-cli/src/config.rs` + +Three changes: + +1. Update import on line 8: + ```rust + // BEFORE: + use fastskill_core::{core::BlobStorageConfig, ServiceConfig}; + // AFTER: + use fastskill_core::ServiceConfig; + ``` + +2. Remove the `registry_blob_storage` block (lines 206–229) — the `if let (Ok(bucket), Ok(region)) = ...` block that reads `S3_BUCKET`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`, `BLOB_BASE_URL`. + +3. Remove `staging_dir` and its references in the `ServiceConfig` initialiser (lines 235, 241, 243): + ```rust + // REMOVE: + let staging_dir = env::var("REGISTRY_STAGING_DIR").ok().map(PathBuf::from); + + // REMOVE from ServiceConfig { ... }: + registry_blob_storage, + staging_dir, + ``` + +--- + +#### `tests/cli/mod.rs` + +Remove two lines: + +```rust +// REMOVE: +pub mod auth_e2e_tests; +pub mod package_tests; +``` + +--- + +#### `.github/workflows/test.yml` + +Remove the second `cargo clippy` step (lines 31–33) and its comment. It was added to lint the default-feature config with `registry-publish` off — that concern disappears when the feature is gone: + +```yaml +# REMOVE: +# Also lint the default-feature config (registry-publish OFF) — this is what +# release binaries are built from, so it must stay clean too. +- run: cargo clippy --workspace --all-targets +``` + +One clippy pass remains (`--all-features`). + +--- + +## Documentation changes + +### webdocs — DELETE (5 files) + +These pages document removed commands or features entirely. Delete the files and remove their nav entries from `mint.json`. + +| File | Why deleted | +|---|---| +| `webdocs/cli-reference/auth-command.mdx` | Documents `fastskill auth login/logout/whoami` | +| `webdocs/cli-reference/package-command.mdx` | Documents `fastskill package` | +| `webdocs/cli-reference/publish-command.mdx` | Documents `fastskill publish` | +| `webdocs/integration/blob-storage.mdx` | Documents S3/blob storage config for registry-publish | +| `webdocs/integration/cicd-pipelines.mdx` | Entire page is `fastskill package` + `fastskill publish` CI patterns; no content remains after removal | + +### webdocs — MODIFY (9 files) + +#### `webdocs/mint.json` + +Remove five entries from the navigation array: + +```json +// REMOVE from cli-reference group: +"cli-reference/auth-command", +"cli-reference/package-command", +"cli-reference/publish-command", + +// REMOVE from integration group: +"integration/blob-storage", +"integration/cicd-pipelines" +``` + +#### `webdocs/cli-reference/overview.mdx` + +- Remove the `` card block (lines 105–107). +- Remove the `` card block (lines 113–115). +- Remove the `` card block (lines 116–118). +- Remove the "opt-in registry publishing builds" config section at the bottom (~lines 192–204) that shows `.fastskill/publish.toml` with `[blob_storage]`. +- In the skill-authoring table (~lines 65–67), remove the three `fastskill package …` rows. + +#### `webdocs/cheatsheet.mdx` + +Remove any rows or sections covering `fastskill auth`, `fastskill package`, or `fastskill publish`. + +#### `webdocs/quickstart.mdx` + +Remove the `fastskill package` step (line 112). If it is part of a "ship your skill" section, remove the entire section or replace with a note that distribution is handled by the platform. + +#### `webdocs/registry/index-system.mdx` + +The page documents the on-disk NDJSON format (still valid) but frames it entirely around the publish write path. Rewrite to: +- Keep: format description, directory structure, `VersionEntry` JSON schema, how to read the index via `GET /registry/index/skills`. +- Remove: all "publishing process" walkthroughs, `fastskill package` + `fastskill publish` code blocks, JWT-scope extraction copy, blob storage URLs, "The registry index is maintained automatically during publishing" paragraph, "How Versions Are Added" section that uses `fastskill publish`. + +#### `webdocs/registry/overview.mdx` + +Remove any references to `fastskill publish`, `fastskill package`, or `registry-publish` feature. Keep registry browsing, `fastskill registry add/remove/list`, and install workflows. + +#### `webdocs/registry/sources.mdx` + +Remove any `fastskill auth` or publish-related content. Registry sources (git, local, http) are unaffected — keep those. + +#### `webdocs/welcome.mdx` + +- Line 22: remove "then discovery and optional operator publishing when you distribute outward" — stop at "validation and `fastskill eval` for quality gates". +- Line 243: remove "Optional private sources and operator publish flows when you distribute". +- Line 279: remove the link to registry overview "when you are ready to publish or browse catalogs" — drop the "publish" qualifier. + +#### `webdocs/index.mdx` + +- Line 16: remove "and **publish** flows when you ship skills to others" from the description sentence. +- Line 44: remove "`package` and `publish` when you distribute" from the registry card copy. +- Line 68: remove the "Authors" bullet that says "package when ready; publish requires an operator build with `registry-publish`." +- Line 71: remove "package, and optional publish in CI" from the Automation bullet. + +--- + +### READMEs — MODIFY (2 files) + +#### `README.md` + +Remove line 90 from the command reference table: + +```markdown + +| `fastskill package` | Package skills for distribution | +``` + +#### `crates/fastskill-core/README.md` + +Remove line 104: + +```markdown + +- `registry-publish`: opt-in registry publishing support. +``` + +--- + +### Project docs — MODIFY (3 files) + +#### `CONTEXT.md` + +- **Artifact** definition (line 57): remove the sentence "A packaged skill produced by `package` — a ZIP with change-detection/version-bump. Input to `publish`." The Artifact concept itself may be removed or rewritten to reflect that packaging is no longer a first-class CLI operation. +- **Registry Index** definition (line 60): remove "The catalog `publish` writes (alongside blobs)". Reframe as: "The on-disk NDJSON catalog read by `fastskill serve` and `registry search`; populated externally (e.g. by the platform operator)." + +#### `AGENTS.md` + +- Line 176: remove `publish` and `auth` from the "Standalone commands" list. Updated: `(init, install, registry)`. +- Line 225: remove the `registry-publish (default) - Publishing to registries with AWS S3` feature bullet. + +#### `CONTRIBUTING.md` + +- Line 169: remove the `registry-publish (default): Enables publishing skills to registries` feature entry. + +--- + +## Complete modified-file list + +### Source — deleted (18 items) + +``` +crates/fastskill-core/src/core/blob_storage.rs +crates/fastskill-core/src/core/packaging.rs +crates/fastskill-core/src/core/registry/index_manager.rs +crates/fastskill-core/src/core/registry/staging.rs +crates/fastskill-core/src/core/registry/validation_worker.rs +crates/fastskill-core/src/http/handlers/registry_publish.rs +crates/fastskill-core/tests/index_manager_test.rs +crates/fastskill-core/tests/registry_atomic_test.rs +crates/fastskill-cli/src/commands/publish.rs +crates/fastskill-cli/src/commands/package.rs +crates/fastskill-cli/src/commands/auth.rs +crates/fastskill-cli/src/auth_config.rs +crates/fastskill-cli/src/utils/api_client.rs +tests/integration_registry_publish_test.rs +tests/fixtures/test-skill-registry-publish/ (directory) +tests/cli/auth_e2e_tests.rs +tests/cli/package_tests.rs +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_custom_role.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_credentials.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_port.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_success.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_unreachable.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_nonexistent.snap +tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_success.snap +``` + +### Source — modified (15 files) + +``` +crates/fastskill-core/Cargo.toml +crates/fastskill-core/src/core/registry.rs +crates/fastskill-core/src/core/mod.rs +crates/fastskill-core/src/core/registry_index.rs +crates/fastskill-core/src/core/service.rs +crates/fastskill-core/src/http/handlers/mod.rs +crates/fastskill-core/src/http/server.rs +crates/fastskill-cli/Cargo.toml +crates/fastskill-cli/src/commands/mod.rs +crates/fastskill-cli/src/mod.rs +crates/fastskill-cli/src/utils.rs +crates/fastskill-cli/src/main.rs +crates/fastskill-cli/src/config.rs +tests/cli/mod.rs +.github/workflows/test.yml +``` + +### Docs — deleted (5 files) + +``` +webdocs/cli-reference/auth-command.mdx +webdocs/cli-reference/package-command.mdx +webdocs/cli-reference/publish-command.mdx +webdocs/integration/blob-storage.mdx +webdocs/integration/cicd-pipelines.mdx +``` + +### Docs — modified (14 files) + +``` +webdocs/mint.json +webdocs/cli-reference/overview.mdx +webdocs/cheatsheet.mdx +webdocs/quickstart.mdx +webdocs/registry/index-system.mdx +webdocs/registry/overview.mdx +webdocs/registry/sources.mdx +webdocs/welcome.mdx +webdocs/index.mdx +README.md +crates/fastskill-core/README.md +CONTEXT.md +AGENTS.md +CONTRIBUTING.md +``` + +--- + +## Verification checklist + +Run in order; each must be green before the next. + +```bash +# 1. Clean build — no aws-sdk, no multer, no missing modules +cargo build --workspace + +# 2. All tests pass +cargo nextest run --workspace --retries 2 + +# 3. No warnings +cargo clippy --workspace --all-features -- -D warnings + +# 4. Formatting clean +cargo fmt --all -- --check +``` + +Confirm: `fastskill --help` no longer lists `auth`, `package`, or `publish` subcommands. All other commands and help text are unchanged. + +--- + +## Implementation order + +1. **Delete all source files** — removes bulk dead code and surfaces remaining compile errors cleanly. +2. **`registry.rs`** — remove three submodule declarations and their `pub use` lines. +3. **`core/mod.rs`** — remove `blob_storage` and `packaging` modules, their re-exports, and the four dead `registry_index` symbols. +4. **`registry_index.rs`** — remove the six write-path symbols. +5. **`service.rs`** — remove `staging_dir` and `registry_blob_storage` fields. +6. **`http/handlers/mod.rs`** + **`http/server.rs`** — remove all `#[cfg(feature = "registry-publish")]` blocks. +7. **Both `Cargo.toml` files** — drop feature flag, AWS deps, `multer`, `base64`, `zip` (CLI only). +8. **CLI entrypoints** — `main.rs`, `commands/mod.rs`, `mod.rs`, `utils.rs`, `config.rs`. +9. **Test wiring** — `tests/cli/mod.rs`, delete snapshots. +10. **CI** — `.github/workflows/test.yml`, remove second clippy pass. +11. `cargo build --workspace` → iterate until green. +12. `cargo nextest run --workspace` → confirm all passing. +13. **Docs** — delete 5 webdocs files, update 14 docs files as specified above. diff --git a/tests/cli/auth_e2e_tests.rs b/tests/cli/auth_e2e_tests.rs deleted file mode 100644 index 02204110..00000000 --- a/tests/cli/auth_e2e_tests.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! E2E tests for auth command -//! -//! These tests execute the CLI binary and verify actual behavior. - -#![allow(clippy::all, clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::snapshot_helpers::{ - assert_snapshot_with_settings, cli_snapshot_settings, run_fastskill_command_with_env, -}; -use serde_json::json; -use std::fs; -use std::io::ErrorKind; -use tempfile::TempDir; -use wiremock::{ - matchers::{method, path}, - Mock, MockServer, ResponseTemplate, -}; - -async fn start_mock_server_or_skip() -> Option { - match std::net::TcpListener::bind("127.0.0.1:0") { - Ok(listener) => Some(MockServer::builder().listener(listener).start().await), - Err(err) if err.kind() == ErrorKind::PermissionDenied => { - eprintln!("Skipping test: unable to bind local mock server socket ({err})"); - None - } - Err(err) => panic!("failed to bind local mock server socket: {err}"), - } -} - -#[tokio::test] -async fn test_auth_login_success() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir_all(&config_dir).unwrap(); - - // Mock server with /auth/token endpoint - let Some(mock_server) = start_mock_server_or_skip().await else { - return; - }; - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { - "token": "jwt-token-here" - } - }))) - .mount(&mock_server) - .await; - - // Set config directory environment variable - let env_vars = vec![ - ("OPENAI_API_KEY", "test-key"), - ("FASTSKILL_CONFIG_DIR", config_dir.to_str().unwrap()), - ]; - - let result = run_fastskill_command_with_env( - &["auth", "login", "--registry", &mock_server.uri()], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(result.success); - assert!(result.stdout.contains("Successfully logged in") || result.stdout.contains("ok")); - - assert_snapshot_with_settings( - "auth_login_success", - &result.stdout, - &cli_snapshot_settings(), - ); -} - -#[tokio::test] -async fn test_auth_login_with_custom_role() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir_all(&config_dir).unwrap(); - - // Mock server with /auth/token endpoint - let Some(mock_server) = start_mock_server_or_skip().await else { - return; - }; - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": { - "token": "jwt-token-manager-role" - } - }))) - .mount(&mock_server) - .await; - - // Set config directory environment variable - let env_vars = vec![ - ("OPENAI_API_KEY", "test-key"), - ("FASTSKILL_CONFIG_DIR", config_dir.to_str().unwrap()), - ]; - - let result = run_fastskill_command_with_env( - &[ - "auth", - "login", - "--registry", - &mock_server.uri(), - "--role", - "manager", - ], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(result.success); - assert!(result.stdout.contains("Successfully logged in") || result.stdout.contains("ok")); - - assert_snapshot_with_settings( - "auth_login_custom_role", - &result.stdout, - &cli_snapshot_settings(), - ); -} - -#[tokio::test] -async fn test_auth_login_unreachable_registry_error() { - let temp_dir = TempDir::new().unwrap(); - - // Try to login to unreachable registry - let env_vars = vec![("OPENAI_API_KEY", "test-key")]; - - let result = run_fastskill_command_with_env( - &["auth", "login", "--registry", "http://localhost:9999"], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(!result.success); - assert!(result.stderr.contains("error") || result.stderr.contains("Failed to connect")); - - assert_snapshot_with_settings( - "auth_login_unreachable", - &format!("{}{}", result.stdout, result.stderr), - &cli_snapshot_settings(), - ); -} - -#[tokio::test] -async fn test_auth_login_invalid_credentials_error() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir_all(&config_dir).unwrap(); - - // Mock server that returns 401 Unauthorized - let Some(mock_server) = start_mock_server_or_skip().await else { - return; - }; - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(401).set_body_string("Invalid credentials")) - .mount(&mock_server) - .await; - - // Set config directory environment variable - let env_vars = vec![ - ("OPENAI_API_KEY", "test-key"), - ("FASTSKILL_CONFIG_DIR", config_dir.to_str().unwrap()), - ]; - - let result = run_fastskill_command_with_env( - &["auth", "login", "--registry", &mock_server.uri()], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(!result.success); - assert!( - result.stderr.contains("error") - || result.stderr.contains("Unauthorized") - || result.stderr.contains("Invalid") - ); - - assert_snapshot_with_settings( - "auth_login_invalid_credentials", - &format!("{}{}", result.stdout, result.stderr), - &cli_snapshot_settings(), - ); -} - -#[test] -fn test_auth_logout_removes_token() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir_all(&config_dir).unwrap(); - - // Create a config with token first - let config_content = format!( - r#"[registry."{}"] -token = "test-token-to-remove" -role = "manager" -"#, - "http://test-registry" - ); - fs::write(config_dir.join("config.toml"), config_content).unwrap(); - - let env_vars = vec![("FASTSKILL_CONFIG_DIR", config_dir.to_str().unwrap())]; - - let result = run_fastskill_command_with_env( - &["auth", "logout", "--registry", "http://test-registry"], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(result.success); - assert!( - result.stdout.contains("logged out") - || result.stdout.contains("Logout") - || result.stdout.contains("removed") - || result.stdout.contains("success") - ); - - assert_snapshot_with_settings( - "auth_logout_success", - &result.stdout, - &cli_snapshot_settings(), - ); -} - -#[tokio::test] -async fn test_auth_logout_nonexistent_registry() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir_all(&config_dir).unwrap(); - - let env_vars = vec![("FASTSKILL_CONFIG_DIR", config_dir.to_str().unwrap())]; - - let result = run_fastskill_command_with_env( - &[ - "auth", - "logout", - "--registry", - "http://nonexistent-registry", - ], - &env_vars, - Some(temp_dir.path()), - ); - - assert!(result.success); - // Logout should be idempotent for non-existent registries - - assert_snapshot_with_settings( - "auth_logout_nonexistent", - &result.stdout, - &cli_snapshot_settings(), - ); -} diff --git a/tests/cli/example_tests.rs b/tests/cli/example_tests.rs index a05c4689..bd73e7fa 100644 --- a/tests/cli/example_tests.rs +++ b/tests/cli/example_tests.rs @@ -29,26 +29,6 @@ fn add_help_shows_examples() { help_succeeds_and_contains_examples(&["add", "--help"]); } -#[test] -fn auth_help_shows_examples() { - help_succeeds_and_contains_examples(&["auth", "--help"]); -} - -#[test] -fn auth_login_help_shows_examples() { - help_succeeds_and_contains_examples(&["auth", "login", "--help"]); -} - -#[test] -fn auth_logout_help_shows_examples() { - help_succeeds_and_contains_examples(&["auth", "logout", "--help"]); -} - -#[test] -fn auth_whoami_help_shows_examples() { - help_succeeds_and_contains_examples(&["auth", "whoami", "--help"]); -} - #[test] fn disable_help_shows_examples() { help_succeeds_and_contains_examples(&["disable", "--help"]); @@ -69,16 +49,6 @@ fn list_help_shows_examples() { help_succeeds_and_contains_examples(&["list", "--help"]); } -#[test] -fn package_help_shows_examples() { - help_succeeds_and_contains_examples(&["package", "--help"]); -} - -#[test] -fn publish_help_shows_examples() { - help_succeeds_and_contains_examples(&["publish", "--help"]); -} - #[test] fn read_help_shows_examples() { help_succeeds_and_contains_examples(&["read", "--help"]); diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index 6335860c..0e61fd7c 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -5,7 +5,6 @@ pub mod add_e2e_tests; pub mod add_tests; pub mod analyze_cluster_tests; -pub mod auth_e2e_tests; pub mod config_tests; pub mod doctor_tests; pub mod eval_tests; @@ -19,7 +18,6 @@ pub mod install_tests; pub mod integration_tests; pub mod legacy_migration_tests; pub mod list_e2e_tests; -pub mod package_tests; pub mod read_e2e_tests; pub mod read_tests; pub mod registry_e2e_tests; diff --git a/tests/cli/package_tests.rs b/tests/cli/package_tests.rs deleted file mode 100644 index f9a3c7f2..00000000 --- a/tests/cli/package_tests.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Integration tests for fastskill package command - -#![allow(clippy::all, clippy::unwrap_used, clippy::expect_used)] - -use assert_cmd::Command; -use fastskill::core::manifest::SkillProjectToml; -use fastskill::core::packaging::package_skill; -use predicates::prelude::*; -use std::fs; -use tempfile::TempDir; - -/// T038: Test fastskill package reading metadata from skill-project.toml -#[test] -fn test_package_reads_metadata_from_skill_project_toml() { - let temp_dir = TempDir::new().unwrap(); - let skill_dir = temp_dir.path().join("test-skill"); - fs::create_dir_all(&skill_dir).unwrap(); - - // Create SKILL.md (required for packaging) with proper YAML frontmatter - fs::write( - skill_dir.join("SKILL.md"), - r#"--- -name: test-skill -description: A test skill for packaging -version: 1.0.0 -author: Test Author -tags: - - test - - packaging -capabilities: - - testing ---- - -# Test Skill - -This is a test skill for packaging tests. -"#, - ) - .unwrap(); - - // Create skill-project.toml with metadata - fs::write( - skill_dir.join("skill-project.toml"), - r#" -[metadata] -id = "test-skill" -version = "1.0.0" -description = "A test skill" -author = "Test Author" -"#, - ) - .unwrap(); - - // Create output directory - let output_dir = temp_dir.path().join("output"); - fs::create_dir_all(&output_dir).unwrap(); - - // Package the skill - let result = package_skill(&skill_dir, &output_dir, "1.0.0"); - assert!(result.is_ok(), "packaging should succeed"); - - // Verify ZIP was created - let zip_path = result.unwrap(); - assert!(zip_path.exists(), "ZIP file should be created"); - assert!(zip_path.to_string_lossy().contains("test-skill-1.0.0.zip")); -} - -/// T039: Test skill dependencies in skill-project.toml -#[test] -fn test_skill_dependencies_in_skill_project_toml() { - let temp_dir = TempDir::new().unwrap(); - let skill_dir = temp_dir.path().join("test-skill"); - fs::create_dir_all(&skill_dir).unwrap(); - - // Create SKILL.md (required for packaging) - fs::write(skill_dir.join("SKILL.md"), "# Test Skill\n").unwrap(); - - // Create skill-project.toml with metadata and dependencies - fs::write( - skill_dir.join("skill-project.toml"), - r#" -[metadata] -id = "test-skill" -version = "1.0.0" - -[dependencies] -dependency1 = "1.0.0" -dependency2 = { source = "git", url = "https://github.com/org/dep2.git" } -"#, - ) - .unwrap(); - - // Verify dependencies can be read - let content = fs::read_to_string(skill_dir.join("skill-project.toml")).unwrap(); - let project: SkillProjectToml = toml::from_str(&content).unwrap(); - - assert!(project.dependencies.is_some()); - let deps = project.dependencies.as_ref().unwrap(); - assert_eq!(deps.dependencies.len(), 2); - assert!(deps.dependencies.contains_key("dependency1")); - assert!(deps.dependencies.contains_key("dependency2")); -} - -/// Test package auto preset command -#[test] -fn test_package_auto_preset() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - let output_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&skills_dir).unwrap(); - fs::create_dir_all(&output_dir).unwrap(); - - let skill_dir = skills_dir.join("test-skill"); - fs::create_dir_all(&skill_dir).unwrap(); - - fs::write( - skill_dir.join("SKILL.md"), - r#"--- -name: test-skill -description: A test skill -version: 1.0.0 -author: Test -tags: [] -capabilities: [] ---- -# Test Skill -"#, - ) - .unwrap(); - - fs::write( - skill_dir.join("skill-project.toml"), - r#" -[metadata] -id = "test-skill" -version = "1.0.0" -"#, - ) - .unwrap(); - - let mut cmd = Command::cargo_bin("fastskill").unwrap(); - cmd.current_dir(&skills_dir) - .arg("package") - .arg("auto") - .arg("--force"); - - cmd.assert().success(); -} - -/// Test package skill preset command -#[test] -fn test_package_skill_preset() { - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - let skill_dir = skills_dir.join("test-skill"); - fs::create_dir_all(&skill_dir).unwrap(); - - fs::write( - skill_dir.join("SKILL.md"), - r#"--- -name: test-skill -description: A test skill -version: 1.0.0 -author: Test -tags: [] -capabilities: [] ---- -# Test Skill -"#, - ) - .unwrap(); - - fs::write( - skill_dir.join("skill-project.toml"), - r#" -[metadata] -id = "test-skill" -version = "1.0.0" -"#, - ) - .unwrap(); - - let mut cmd = Command::cargo_bin("fastskill").unwrap(); - cmd.current_dir(&skills_dir) - .arg("package") - .arg("skill") - .arg("test-skill"); - - cmd.assert().success(); -} - -/// Test package preset shows help correctly -#[test] -fn test_package_preset_help() { - let mut cmd = Command::cargo_bin("fastskill").unwrap(); - cmd.arg("package").arg("auto").arg("--help"); - - cmd.assert().success().stdout(predicate::str::contains( - "Auto-detect and package changed skills", - )); -} - -/// Test package skill preset empty skills error (PKG_002) -#[test] -fn test_package_skill_preset_empty_error() { - let temp_dir = TempDir::new().unwrap(); - - let mut cmd = Command::cargo_bin("fastskill").unwrap(); - cmd.current_dir(temp_dir.path()).arg("package").arg("skill"); - - cmd.assert() - .failure() - .stderr(predicate::str::contains("required")); -} diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_custom_role.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_custom_role.snap deleted file mode 100644 index 0cdbdc41..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_custom_role.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -Logging in to registry: http://[VERSION].1:[PORT] -[OK] Successfully logged in to http://[VERSION].1:[PORT] -Token saved and will be used automatically diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_credentials.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_credentials.snap deleted file mode 100644 index 1efcca60..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_credentials.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -Logging in to registry: http://[VERSION].1:[PORT] -Error: Configuration error: Login failed with status 401 Unauthorized: Invalid credentials diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_port.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_port.snap deleted file mode 100644 index 86ccc48d..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_invalid_port.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -assertion_line: 195 -expression: normalized ---- -Error: Configuration error: Failed to connect to registry: builder error: invalid port number diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_success.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_success.snap deleted file mode 100644 index 0cdbdc41..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_success.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -Logging in to registry: http://[VERSION].1:[PORT] -[OK] Successfully logged in to http://[VERSION].1:[PORT] -Token saved and will be used automatically diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_unreachable.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_unreachable.snap deleted file mode 100644 index 77472b53..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_login_unreachable.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -Logging in to registry: http://localhost:9999 -Error: Configuration error: Failed to connect to registry: error sending request for url (http://localhost:9999/auth/token): error trying to connect: tcp connect error: [NETWORK_ERROR] diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_nonexistent.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_nonexistent.snap deleted file mode 100644 index 91a5bcb6..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_nonexistent.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -[OK] Successfully logged out from http://nonexistent-registry diff --git a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_success.snap b/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_success.snap deleted file mode 100644 index 25ff5515..00000000 --- a/tests/cli/snapshots/cli_tests__cli__snapshot_helpers__auth_logout_success.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tests/cli/snapshot_helpers.rs -expression: normalized ---- -FastSkill [VERSION] -[OK] Successfully logged out from http://test-registry diff --git a/tests/fixtures/test-skill-registry-publish/README.md b/tests/fixtures/test-skill-registry-publish/README.md deleted file mode 100644 index 91b4a5ba..00000000 --- a/tests/fixtures/test-skill-registry-publish/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Test Skill for Registry Publishing - -This is a **fake test skill** created specifically for integration testing of the production registry publishing workflow. - -## ⚠️ WARNING - -**DO NOT USE THIS SKILL IN PRODUCTION** - -This skill is intended **ONLY** for testing purposes. It should never be used in actual production environments. - -## Purpose - -This test skill is used to verify: -- Skill packaging functionality -- Publishing to production registry API -- Registry index updates -- Skill download and installation from registry -- End-to-end registry workflow - -## Structure - -- `SKILL.md` - Skill metadata and documentation -- `skill-info.toml` - Skill configuration file -- `main.py` - Fake Python script (for testing purposes only) - -## Usage in Tests - -This skill is used by integration tests in `integration_registry_publish_test.rs`. The tests are disabled by default and must be run manually with the `--ignored` flag. - -## Version - -Current version: 1.0.0 - -To test version updates, modify the version in both `SKILL.md` and `skill-info.toml`. - diff --git a/tests/fixtures/test-skill-registry-publish/SKILL.md b/tests/fixtures/test-skill-registry-publish/SKILL.md deleted file mode 100644 index f1632d35..00000000 --- a/tests/fixtures/test-skill-registry-publish/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: test-skill-registry-publish -description: A fake test skill for production registry publishing integration tests -version: 1.0.0 -author: FastSkill Test Suite -tags: - - test - - integration - - registry -capabilities: - - testing - - registry-publishing ---- - -# Test Skill for Registry Publishing - -This is a fake test skill created specifically for testing the production registry publishing workflow. - -## Purpose - -This skill is used in integration tests to verify: -- Skill packaging -- Publishing to production registry API -- Registry index updates -- Skill download and installation from registry - -## Warning - -**DO NOT USE IN PRODUCTION** - This is a test-only skill. - diff --git a/tests/fixtures/test-skill-registry-publish/main.py b/tests/fixtures/test-skill-registry-publish/main.py deleted file mode 100644 index d7489e82..00000000 --- a/tests/fixtures/test-skill-registry-publish/main.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -""" -Test skill main script for registry publishing tests. - -This is a fake skill implementation used only for testing. -""" - -def main(): - """Main entry point for the test skill.""" - print("This is a test skill for registry publishing integration tests.") - return {"status": "success", "message": "Test skill executed"} - -if __name__ == "__main__": - main() - diff --git a/tests/fixtures/test-skill-registry-publish/skill-info.toml b/tests/fixtures/test-skill-registry-publish/skill-info.toml deleted file mode 100644 index 4e940e7e..00000000 --- a/tests/fixtures/test-skill-registry-publish/skill-info.toml +++ /dev/null @@ -1,8 +0,0 @@ -[metadata] -name = "test-skill-registry-publish" -version = "1.0.0" -description = "A fake test skill for production registry publishing integration tests" -author = "FastSkill Test Suite" -tags = ["test", "integration", "registry"] -capabilities = ["testing", "registry-publishing"] - diff --git a/tests/integration_registry_publish_test.rs b/tests/integration_registry_publish_test.rs deleted file mode 100644 index 56797619..00000000 --- a/tests/integration_registry_publish_test.rs +++ /dev/null @@ -1,708 +0,0 @@ -//! Integration tests for publishing skills to production registry -//! -//! **IMPORTANT**: These tests are disabled by default and should only be run manually. -#![allow(clippy::all, clippy::unwrap_used, clippy::expect_used, clippy::panic)] -//! -//! They require: -//! - Access to production registry at https://api.fastskill.io -//! - Valid JWT token for authentication -//! - Network connectivity to production infrastructure -//! -//! To run these tests manually: -//! ```bash -//! cargo test --test integration_registry_publish_test -- --ignored --nocapture -//! ``` -//! -//! Or run a specific test: -//! ```bash -//! cargo test --test integration_registry_publish_test test_publish_to_production_registry -- --ignored --nocapture -//! ``` - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tempfile::TempDir; - -const PRODUCTION_REGISTRY_URL: &str = "https://api.fastskill.io"; -const TEST_SKILL_NAME: &str = "test-skill-registry-publish"; -const TEST_SKILL_VERSION: &str = "1.0.0"; - -/// Get the path to the test skill fixture -fn get_test_skill_path() -> PathBuf { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - PathBuf::from(manifest_dir) - .join("tests") - .join("fixtures") - .join(TEST_SKILL_NAME) -} - -/// Get the fastskill binary path (resolves under coverage, debug, and release). -fn get_fastskill_binary() -> PathBuf { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - let base = PathBuf::from(manifest_dir); - let candidates = [ - base.join("target") - .join("llvm-cov-target") - .join("debug") - .join("fastskill"), - base.join("target").join("debug").join("fastskill"), - base.join("target").join("release").join("fastskill"), - ]; - for path in &candidates { - if path.exists() { - return path.clone(); - } - } - base.join("target").join("debug").join("fastskill") -} - -/// Validate that a ZIP package contains the expected version in skill-project.toml -fn validate_package_version(package_path: &Path, expected_version: &str) -> bool { - use std::io::Read; - use zip::ZipArchive; - - let file = match fs::File::open(package_path) { - Ok(f) => f, - Err(_) => return false, - }; - - let mut archive = match ZipArchive::new(file) { - Ok(a) => a, - Err(_) => return false, - }; - - // Find skill-project.toml in the ZIP - for i in 0..archive.len() { - let mut file = match archive.by_index(i) { - Ok(f) => f, - Err(_) => continue, - }; - - if file.name().ends_with("skill-project.toml") { - let mut content = String::new(); - if file.read_to_string(&mut content).is_err() { - return false; - } - - // Parse the TOML and check version - if let Ok(toml_value) = content.parse::() { - if let Some(metadata) = toml_value.get("metadata") { - if let Some(version) = metadata.get("version") { - if let Some(version_str) = version.as_str() { - return version_str == expected_version; - } - } - } - } - break; - } - } - - false -} - -/// Check if production registry tests should run -fn should_run_production_tests() -> bool { - env::var("FASTSKILL_TEST_PRODUCTION_REGISTRY") - .map(|v| v == "1" || v.to_lowercase() == "true") - .unwrap_or(false) -} - -/// Get JWT token from environment -fn get_jwt_token() -> Option { - env::var("FASTSKILL_API_TOKEN").ok() -} - -/// Create a test skill package -fn create_test_package( - skill_path: &Path, - output_dir: &Path, -) -> Result> { - let binary = get_fastskill_binary(); - - if !binary.exists() { - return Err("fastskill binary not found. Run 'cargo build' first.".into()); - } - - let output = Command::new(&binary) - .arg("package") - .arg("--skills") - .arg(TEST_SKILL_NAME) - .arg("--output") - .arg(output_dir) - .current_dir(skill_path.parent().unwrap()) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Package command failed: {}", stderr).into()); - } - - // Find the created ZIP file - let zip_file = output_dir.join(format!("{}-{}.zip", TEST_SKILL_NAME, TEST_SKILL_VERSION)); - if zip_file.exists() { - Ok(zip_file) - } else { - // Try to find any ZIP file in the output directory - let entries = fs::read_dir(output_dir)?; - for entry in entries { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("zip") { - return Ok(path); - } - } - Err("No ZIP file created".into()) - } -} - -/// Test: Package the test skill -#[test] -#[ignore] -fn test_package_test_skill() { - let test_skill_path = get_test_skill_path(); - assert!( - test_skill_path.exists(), - "Test skill fixture not found at: {}", - test_skill_path.display() - ); - - let temp_dir = TempDir::new().unwrap(); - let output_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&output_dir).unwrap(); - - let package_path = - create_test_package(&test_skill_path, &output_dir).expect("Failed to create test package"); - - assert!( - package_path.exists(), - "Package file not created: {}", - package_path.display() - ); - - println!("✓ Test package created: {}", package_path.display()); -} - -/// Test: Publish skill to production registry -#[tokio::test] -#[ignore] -async fn test_publish_to_production_registry() { - if !should_run_production_tests() { - eprintln!("Skipping production registry test. Set FASTSKILL_TEST_PRODUCTION_REGISTRY=1 to enable."); - return; - } - - let token = get_jwt_token().expect( - "FASTSKILL_API_TOKEN environment variable must be set for production registry tests", - ); - - let test_skill_path = get_test_skill_path(); - let temp_dir = TempDir::new().unwrap(); - let output_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&output_dir).unwrap(); - - // Step 1: Package the skill - println!("Step 1: Packaging test skill..."); - let package_path = - create_test_package(&test_skill_path, &output_dir).expect("Failed to create test package"); - println!("✓ Package created: {}", package_path.display()); - - // Step 2: Publish to production registry - println!("Step 2: Publishing to production registry..."); - let binary = get_fastskill_binary(); - - let output = Command::new(&binary) - .arg("publish") - .arg("--artifacts") - .arg(&package_path) - .arg("--api-url") - .arg(PRODUCTION_REGISTRY_URL) - .arg("--token") - .arg(&token) - .arg("--wait") - .arg("--max-wait") - .arg("600") // 10 minutes timeout - .output() - .expect("Failed to execute publish command"); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - println!("Publish stdout:\n{}", stdout); - if !stderr.is_empty() { - eprintln!("Publish stderr:\n{}", stderr); - } - - assert!( - output.status.success(), - "Publish command failed. Exit code: {}", - output.status.code().unwrap_or(-1) - ); - - // Verify output contains success indicators - assert!( - stdout.contains("accepted") - || stdout.contains("queued") - || stdout.contains("Package accepted"), - "Publish output doesn't indicate success. Output: {}", - stdout - ); - - println!("✓ Skill published to production registry"); -} - -/// Test: Verify skill appears in registry after publishing -#[tokio::test] -#[ignore] -async fn test_verify_skill_in_registry() { - if !should_run_production_tests() { - eprintln!("Skipping production registry test. Set FASTSKILL_TEST_PRODUCTION_REGISTRY=1 to enable."); - return; - } - - // Wait a bit for registry to update - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - - // Check if skill is in registry via API - let client = reqwest::Client::new(); - let url = format!("{}/api/registry/skills", PRODUCTION_REGISTRY_URL); - - let response = client - .get(&url) - .send() - .await - .expect("Failed to query registry API"); - - assert!( - response.status().is_success(), - "Registry API returned error: {}", - response.status() - ); - - let json: serde_json::Value = response - .json() - .await - .expect("Failed to parse registry API response"); - - println!( - "Registry API response: {}", - serde_json::to_string_pretty(&json).unwrap() - ); - - // Check if our test skill is in the list - if let Some(data) = json.get("data") { - if let Some(skills) = data.get("skills") { - let skills_array = skills.as_array().unwrap(); - let found = skills_array.iter().any(|skill| { - skill - .get("id") - .and_then(|v| v.as_str()) - .map(|id| id == TEST_SKILL_NAME) - .unwrap_or(false) - }); - - if found { - println!("✓ Test skill found in registry"); - } else { - println!( - "⚠ Test skill not yet visible in registry (may need more time to propagate)" - ); - } - } - } -} - -/// Test: Download and install skill from production registry -#[tokio::test] -#[ignore] -async fn test_download_from_production_registry() { - if !should_run_production_tests() { - eprintln!("Skipping production registry test. Set FASTSKILL_TEST_PRODUCTION_REGISTRY=1 to enable."); - return; - } - - let temp_dir = TempDir::new().unwrap(); - let skills_dir = temp_dir.path().join(".claude").join("skills"); - fs::create_dir_all(&skills_dir).unwrap(); - - // Configure repository to use production registry - let repos_dir = temp_dir.path().join(".claude"); - fs::create_dir_all(&repos_dir).unwrap(); - let repos_toml = repos_dir.join("repositories.toml"); - fs::write( - &repos_toml, - format!( - r#"[[repositories]] -name = "official" -type = "git-registry" -index_url = "sparse+{}/index" -priority = 0 -"#, - PRODUCTION_REGISTRY_URL - ), - ) - .unwrap(); - - let binary = get_fastskill_binary(); - - // Try to add the skill from registry - let output = Command::new(&binary) - .arg("add") - .arg(TEST_SKILL_NAME) - .env( - "FASTSKILL_SKILLS_TOML_PATH", - temp_dir.path().join(".claude").join("skills.toml"), - ) - .current_dir(temp_dir.path()) - .output() - .expect("Failed to execute add command"); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - println!("Add stdout:\n{}", stdout); - if !stderr.is_empty() { - eprintln!("Add stderr:\n{}", stderr); - } - - // Note: This might fail if skill isn't published yet, which is okay for testing - if output.status.success() { - println!("✓ Skill successfully downloaded and installed from registry"); - - // Verify skill was installed - let skill_path = skills_dir.join(TEST_SKILL_NAME); - assert!( - skill_path.exists(), - "Skill directory not created after installation" - ); - } else { - println!("⚠ Skill download failed (may not be published yet or registry not configured)"); - } -} - -/// Test: Complete end-to-end workflow -#[tokio::test] -#[ignore] -async fn test_complete_publish_workflow() { - if !should_run_production_tests() { - eprintln!("Skipping production registry test. Set FASTSKILL_TEST_PRODUCTION_REGISTRY=1 to enable."); - return; - } - - let token = get_jwt_token().expect( - "FASTSKILL_API_TOKEN environment variable must be set for production registry tests", - ); - - println!("=== Complete Production Registry Publish Workflow ==="); - println!(); - - // Step 1: Package - println!("[1/4] Packaging test skill..."); - let test_skill_path = get_test_skill_path(); - let temp_dir = TempDir::new().unwrap(); - let output_dir = temp_dir.path().join("artifacts"); - fs::create_dir_all(&output_dir).unwrap(); - - let package_path = - create_test_package(&test_skill_path, &output_dir).expect("Failed to create test package"); - println!("✓ Package created: {}", package_path.display()); - println!(); - - // Step 2: Publish - println!("[2/4] Publishing to production registry..."); - let binary = get_fastskill_binary(); - - let output = Command::new(&binary) - .arg("publish") - .arg("--artifacts") - .arg(&package_path) - .arg("--api-url") - .arg(PRODUCTION_REGISTRY_URL) - .arg("--token") - .arg(&token) - .arg("--wait") - .arg("--max-wait") - .arg("600") - .output() - .expect("Failed to execute publish command"); - - let stdout = String::from_utf8_lossy(&output.stdout); - println!("{}", stdout); - - assert!( - output.status.success(), - "Publish failed. Stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - println!("✓ Published to registry"); - println!(); - - // Step 3: Wait for propagation - println!("[3/4] Waiting for registry to update..."); - tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; - println!("✓ Registry should be updated"); - println!(); - - // Step 4: Verify via API - println!("[4/4] Verifying skill in registry..."); - let client = reqwest::Client::new(); - let url = format!("{}/api/registry/skills", PRODUCTION_REGISTRY_URL); - - let response = client - .get(&url) - .send() - .await - .expect("Failed to query registry API"); - - assert!( - response.status().is_success(), - "Registry API returned error: {}", - response.status() - ); - - let json: serde_json::Value = response - .json() - .await - .expect("Failed to parse registry API response"); - - println!( - "Registry contains {} skills", - json.get("data") - .and_then(|d| d.get("totalSkills")) - .and_then(|v| v.as_u64()) - .unwrap_or(0) - ); - - println!(); - println!("=== Workflow Complete ==="); - println!("✓ Test skill published to production registry"); - println!(" Registry URL: {}", PRODUCTION_REGISTRY_URL); - println!(" Skill: {}@{}", TEST_SKILL_NAME, TEST_SKILL_VERSION); -} - -/// End-to-end integration test for version conflict detection -/// Tests publishing packages with versions 1.0.0, 1.0.1, 1.0.2, then tries to publish 1.0.2 again (should fail) -#[tokio::test] -async fn test_version_conflict_detection_e2e() { - println!(); - println!("=== Testing Version Conflict Detection (E2E) ==="); - - // Setup temporary registry and packages directory - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let registry_path = temp_dir.path().join("registry"); - let packages_path = temp_dir.path().join("packages"); - - fs::create_dir_all(®istry_path).expect("Failed to create registry directory"); - fs::create_dir_all(&packages_path).expect("Failed to create packages directory"); - - // Create test skill directory structure - let skill_dir = packages_path.join("test-skill-conflict"); - fs::create_dir_all(&skill_dir).expect("Failed to create skill directory"); - - // Create SKILL.md - let skill_md = r#"--- -name: test-skill-conflict -version: "1.0.0" -description: Test skill for version conflict detection -license: MIT ---- - -# Test Skill Conflict - -This is a test skill for version conflict detection. -"#; - fs::write(skill_dir.join("SKILL.md"), skill_md).expect("Failed to write SKILL.md"); - - // Create skill-project.toml template - let skill_project_template = r#"[metadata] -name = "test-skill-conflict" -description = "Test skill for version conflict detection" -version = "{}" -author = "Test Author" - -[dependencies] -"#; - - // Get fastskill binary path - let fastskill_binary = get_fastskill_binary(); - - // Test versions: 1.0.0, 1.0.1, 1.0.2 (success), then 1.0.2 again (should fail) - let versions = vec!["1.0.0", "1.0.1", "1.0.2", "1.0.2"]; - let mut expected_results = vec![true, true, true, false]; // Last one should fail - - // Track published versions to detect conflicts - let mut published_versions = std::collections::HashSet::new(); - - for (i, version) in versions.iter().enumerate() { - println!("Testing version {} (attempt {})", version, i + 1); - - // Update skill-project.toml with current version - let skill_project_content = skill_project_template.replace("{}", version); - fs::write(skill_dir.join("skill-project.toml"), &skill_project_content) - .expect("Failed to write skill-project.toml"); - - // Package the skill - let package_name = format!("test-skill-conflict-{}.zip", version); - let package_path = packages_path.join(&package_name); - - println!( - " → Running: fastskill package --skills test-skill-conflict --output {} --force", - packages_path.display() - ); - - let package_result = Command::new(&fastskill_binary) - .args(&[ - "package", - "--skills-dir", - packages_path.to_str().unwrap(), - "--skills", - "test-skill-conflict", - "--output", - packages_path.to_str().unwrap(), - "--force", - ]) - .output() - .expect("Failed to run fastskill package"); - - // Check packaging result - if package_result.status.success() { - println!(" ✓ Package command: PASS"); - } else { - println!(" ✗ Package command: FAIL"); - panic!( - "Failed to package skill: {}", - String::from_utf8_lossy(&package_result.stderr) - ); - } - - // Verify package was created - if package_path.exists() { - println!(" ✓ Package file created: PASS ({})", package_name); - } else { - println!(" ✗ Package file created: FAIL ({})", package_name); - panic!("Package {} was not created", package_name); - } - - // Validate package contents - check version in ZIP - let version_check = validate_package_version(&package_path, version); - if version_check { - println!( - " ✓ Package version validation: PASS (contains version {})", - version - ); - } else { - println!( - " ✗ Package version validation: FAIL (missing or wrong version {})", - version - ); - panic!( - "Package {} does not contain correct version {}", - package_name, version - ); - } - - // Check for version conflicts - if published_versions.contains(&version.to_string()) { - // This is a duplicate version - should be rejected - println!( - " ✗ Version conflict check: FAIL (duplicate version {})", - version - ); - expected_results[i] = false; - } else { - // New version - add to published set - published_versions.insert(version.to_string()); - println!(" ✓ Version conflict check: PASS (new version {})", version); - } - - // Overall step result - let step_success = expected_results[i]; - if step_success { - println!( - " 🎯 Step {}: PASS - Version {} successfully processed", - i + 1, - version - ); - } else { - println!( - " 🎯 Step {}: EXPECTED FAIL - Version {} correctly rejected (duplicate)", - i + 1, - version - ); - } - println!(); - } - - // Final verification - println!("=== Final Verification ==="); - let mut final_pass_count = 0; - let final_total_count = expected_results.len(); - - for (i, &expected_success) in expected_results.iter().enumerate() { - let version = versions[i]; - let package_name = format!("test-skill-conflict-{}.zip", version); - let package_path = packages_path.join(&package_name); - - print!("Version {}: ", version); - - // Check package exists - if !package_path.exists() { - println!("✗ FAIL - Package file missing"); - continue; - } - - // Check package contains correct version - if !validate_package_version(&package_path, version) { - println!("✗ FAIL - Wrong version in package"); - continue; - } - - // Check version conflict logic - simulate what should happen in real publishing - let should_be_duplicate = match version { - "1.0.0" => false, // First time seeing 1.0.0 - "1.0.1" => false, // First time seeing 1.0.1 - "1.0.2" => i > 2, // 1.0.2 is duplicate only on the 4th attempt (i=3) - _ => false, - }; - let conflict_check_pass = (expected_success && !should_be_duplicate) - || (!expected_success && should_be_duplicate); - - if conflict_check_pass { - println!( - "✓ PASS - {}", - if expected_success { - "Successfully processed" - } else { - "Correctly rejected (duplicate)" - } - ); - final_pass_count += 1; - } else { - println!("✗ FAIL - Conflict detection logic error"); - } - } - - println!(); - println!("=== Test Results ==="); - println!("Total steps: {}", final_total_count); - println!("Passed steps: {}", final_pass_count); - println!("Failed steps: {}", final_total_count - final_pass_count); - - if final_pass_count == final_total_count { - println!("🎯 OVERALL RESULT: PASS - All steps completed successfully!"); - println!("✓ Successfully packaged versions: 1.0.0, 1.0.1, 1.0.2"); - println!("✓ Correctly rejected duplicate version: 1.0.2"); - } else { - println!( - "❌ OVERALL RESULT: FAIL - {} steps failed", - final_total_count - final_pass_count - ); - } - - println!("✓ All packages saved to: {}", packages_path.display()); - println!( - "✓ Test completed in temporary directory: {}", - temp_dir.path().display() - ); -} diff --git a/webdocs/cheatsheet.mdx b/webdocs/cheatsheet.mdx index 7c598614..9afe627d 100644 --- a/webdocs/cheatsheet.mdx +++ b/webdocs/cheatsheet.mdx @@ -62,9 +62,6 @@ Modern agents (Claude Code, Cursor, …) read skills directly from the skills di | Initialize project | `fastskill init` | Reads `SKILL.md` frontmatter, prompts for gaps, writes `skill-project.toml` | | Init with version | `fastskill init --version 1.2.3` | Sets version without prompting | | Init non-interactive | `fastskill init --yes` | Defaults from `SKILL.md`, no prompts | -| Package | `fastskill package --output ./artifacts` | Builds ZIP from `skill-project.toml` version with checksums and metadata | -| Package changed only | `fastskill package --detect-changes --auto-bump` | Hash-based change detection, version bump, package only what changed | -| Package subset | `fastskill package --skills web-scraper data-processor` | Packages only listed skills | ## Repositories and catalog diff --git a/webdocs/cli-reference/auth-command.mdx b/webdocs/cli-reference/auth-command.mdx deleted file mode 100644 index 4c83c85e..00000000 --- a/webdocs/cli-reference/auth-command.mdx +++ /dev/null @@ -1,566 +0,0 @@ ---- -title: "Auth Command" -description: "FastSkill CLI command for authentication and authorization management." ---- - -## Overview - -The `auth` command manages authentication for FastSkill registries. It supports JWT-based authentication with role-based access control (RBAC). Authentication tokens are stored locally and used automatically when making API requests. - - -Authentication is required for: -- Publishing skills to registries in `registry-publish` builds -- Accessing private repositories -- Performing admin operations on registries - - -## Subcommands - -### auth login - -Authenticate with a registry and store the authentication token. - -```bash -# Login to default registry (http://localhost:8080) -fastskill auth login - -# Login to specific registry -fastskill auth login --registry https://registry.example.com - -# Login with specific role -fastskill auth login --registry https://registry.example.com --role admin - -# Login using environment variable -export FASTSKILL_API_URL=https://registry.example.com -fastskill auth login --role manager -``` - -**Options**: - -| Flag | Short | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--registry` | - | string | `FASTSKILL_API_URL` env var or `http://localhost:8080` | Registry URL to authenticate with | -| `--role` | - | string | `manager` | Role for the token (`user`, `manager`, or `admin`) | - -**What happens during login**: -1. CLI sends POST request to `{registry}/auth/token` with specified role -2. Registry validates the request and generates a JWT token -3. Token is returned and stored in configuration -4. Subsequent API requests use the token automatically - -**Token storage location**: -- Stored in `~/.fastskill/auth.toml` (or platform-specific config directory) -- Tokens are stored per-registry -- Encrypted or stored securely based on platform - -**Roles**: - -| Role | Permissions | Use Case | -|------|-------------|-----------| -| `user` | Read skills, search registry | General users | -| `manager` | User permissions + publish skills, manage skills | Skill authors and maintainers | -| `admin` | Manager permissions + manage users, configure registry | Registry administrators | - -**Example workflow**: - -```bash -# 1. Login as manager (default) -fastskill auth login --registry https://registry.example.com -# Prompts for credentials (if needed) -# Token stored and ready to use - -# 2. Publish a skill -fastskill publish --artifacts ./artifacts/pptx-1.2.3.zip --api-url https://registry.example.com - -# 3. Token is used automatically -``` - -### auth logout - -Remove stored authentication token for a registry. - -```bash -# Logout from default registry -fastskill auth logout - -# Logout from specific registry -fastskill auth logout --registry https://registry.example.com - -# Logout using environment variable -export FASTSKILL_API_URL=https://registry.example.com -fastskill auth logout -``` - -**Options**: - -| Flag | Short | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--registry` | - | string | `FASTSKILL_API_URL` env var or `http://localhost:8080` | Registry URL to logout from | - -**What happens during logout**: -1. Token for specified registry is removed from local storage -2. No API call is made to registry -3. Existing sessions (if any) are not terminated server-side -4. Future API requests will fail until re-authentication - -**Example**: - -```bash -# 1. Login -fastskill auth login --registry https://registry.example.com --role manager -# Output: Successfully logged in to https://registry.example.com - -# 2. Logout -fastskill auth logout --registry https://registry.example.com -# Output: Successfully logged out from https://registry.example.com - -# 3. Try to publish (will fail) -fastskill publish --artifacts ./artifacts/pptx-1.2.3.zip --api-url https://registry.example.com -# Remote registry rejects the request if it requires authentication -``` - -### auth whoami - -Display current authentication status and information. - -```bash -# Check auth status for default registry -fastskill auth whoami - -# Check auth status for specific registry -fastskill auth whoami --registry https://registry.example.com - -# Check auth status using environment variable -export FASTSKILL_API_URL=https://registry.example.com -fastskill auth whoami -``` - -**Options**: - -| Flag | Short | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--registry` | - | string | `FASTSKILL_API_URL` env var or `http://localhost:8080` | Registry URL to check | - -**What it displays**: -- Registry URL -- Username (if available) -- Role -- Token expiration (if available) -- Last refresh timestamp (if available) - -**Example output**: - -``` -Registry: https://registry.example.com -Username: dev-user -Role: manager -Token expires: 2025-02-15T10:30:00Z -Last refresh: 2025-01-15T10:30:00Z -``` - -**Not logged in**: - -``` -Not logged in to registry: https://registry.example.com -Run `fastskill auth login` to authenticate -``` - -**Using environment variable token**: - -``` -Using token from FASTSKILL_API_TOKEN environment variable -Registry: https://registry.example.com -Note: Environment variable tokens don't store user info -``` - -## Environment Variables - -### FASTSKILL_API_URL - -Default registry URL for all auth commands. - -```bash -# Set default registry -export FASTSKILL_API_URL=https://registry.example.com - -# Now can omit --registry flag -fastskill auth login --role admin -fastskill auth whoami -fastskill auth logout -``` - -### FASTSKILL_API_TOKEN - -Directly provide authentication token (bypasses storage). - -```bash -# Set token directly (e.g., from CI/CD) -export FASTSKILL_API_TOKEN=eyJhbGc... - -# Token is used automatically for all API requests -fastskill publish --artifacts ./artifacts/pptx-1.2.3.zip - -# whoami shows token is from environment variable -fastskill auth whoami -# Output: Using token from FASTSKILL_API_TOKEN environment variable -``` - - -**Security note**: Environment variables may be visible in process lists and logs. Avoid setting sensitive tokens in environment variables for long-running processes. - - -## Per-Registry Authentication - -FastSkill supports authenticating with multiple registries simultaneously. - -```bash -# Login to production registry -fastskill auth login --registry https://prod-registry.example.com --role manager - -# Login to staging registry -fastskill auth login --registry https://staging-registry.example.com --role user - -# Check status for each -fastskill auth whoami --registry https://prod-registry.example.com -fastskill auth whoami --registry https://staging-registry.example.com -``` - -**Token storage**: -- Each registry has its own stored token -- Tokens are isolated and don't interfere -- CLI automatically selects the correct token based on target registry - -**Usage**: - -```bash -# Publish to production -fastskill publish --api-url https://prod-registry.example.com --artifacts ./artifacts/pptx-1.2.3.zip - -# Publish to staging -fastskill publish --api-url https://staging-registry.example.com --artifacts ./artifacts/pptx-1.2.3.zip -``` - -## Role-Based Access Control (RBAC) - -FastSkill implements a role hierarchy for authentication. - -### Role Hierarchy - -``` -admin - └─ manager - └─ user -``` - -- **admin** has all permissions of manager and user -- **manager** has all permissions of user -- **user** has basic read permissions - -### Permissions Matrix - -| Operation | user | manager | admin | -|-----------|-------|---------|--------| -| List skills | ✓ | ✓ | ✓ | -| Search skills | ✓ | ✓ | ✓ | -| Download skills | ✓ | ✓ | ✓ | -| Publish skills | ✗ | ✓ | ✓ | -| Update skills | ✗ | ✓ | ✓ | -| Delete skills | ✗ | ✓ | ✓ | -| Manage users | ✗ | ✗ | ✓ | -| Configure registry | ✗ | ✗ | ✓ | -| View metrics | ✗ | ✓ | ✓ | - -### Choosing the Right Role - - - -Use for: -- Skill consumers who only need to download and use skills -- Read-only access to registry -- Public skill browsing - - - -Use for: -- Skill authors and maintainers -- Publishing and updating skills -- Managing skill metadata -- Viewing registry metrics - - - -Use for: -- Registry administrators -- Managing user accounts -- Configuring registry settings -- Full control over registry operations - - - -## Authentication Flow - -### 1. Token Generation - -``` -Client Server - | | - |-- POST /auth/token --------->| - | { "role": "manager" } | - | | - |<-- JWT Token ---------------| - | { "token": "eyJhb...", | - | "expires_at": "..." } | - | | - |-- Store token locally --------->| -``` - -### 2. Token Usage - -``` -Client Server - | | - |-- POST /api/skills ------>| - | Authorization: Bearer | - | eyJhb... | - | | - |<-- Response ---------------| - | { "data": [...] } | - | | -``` - -### 3. Token Refresh - -FastSkill automatically refreshes tokens when needed (if supported by registry). - -```bash -# Token is stored with expiration -# CLI checks expiration before each request -# If expired, CLI attempts to refresh (if refresh token available) - -# Manual refresh (logout then login) -fastskill auth logout -fastskill auth login -``` - -## Configuration File - -Authentication tokens are stored in `~/.fastskill/auth.toml` (Unix) or `%USERPROFILE%\.fastskill\auth.toml` (Windows). - -**Example `auth.toml`**: - -```toml -[[registries]] -registry_url = "https://prod-registry.example.com" -username = "dev-user" -role = "manager" -token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -expires_at = "2025-02-15T10:30:00Z" -last_refresh = "2025-01-15T10:30:00Z" - -[[registries]] -registry_url = "https://staging-registry.example.com" -username = "dev-user" -role = "user" -token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -expires_at = "2025-02-20T10:30:00Z" -last_refresh = "2025-01-20T10:30:00Z" -``` - - -**Security**: Never commit `auth.toml` to version control. It contains sensitive authentication tokens. - - -## Examples - -### Typical Development Workflow - -```bash -# 1. Set registry URL -export FASTSKILL_API_URL=https://registry.example.com - -# 2. Login as manager (skill author) -fastskill auth login --role manager - -# 3. Verify login -fastskill auth whoami - -# 4. Publish skill -fastskill publish --artifacts ./artifacts/pptx-1.2.3.zip - -# 5. Check status -fastskill auth whoami -``` - -### CI/CD Integration - -```bash -#!/bin/bash -# ci-publish.sh - -# 1. Get token from CI/CD secrets -TOKEN=${FASTSKILL_API_TOKEN} - -# 2. Set environment variable -export FASTSKILL_API_TOKEN=$TOKEN -export FASTSKILL_API_URL=https://registry.example.com - -# 3. Package skill -fastskill package --skills pptx --bump patch --output ./artifacts - -# 4. Publish (requires a registry-publish build; token is used automatically) -fastskill publish --artifacts ./artifacts --api-url https://registry.example.com - -# 5. Verify -echo "Published successfully!" -``` - -### Multi-Registry Workflow - -```bash -# 1. Login to production as admin -fastskill auth login --registry https://prod.example.com --role admin - -# 2. Login to staging as manager -fastskill auth login --registry https://staging.example.com --role manager - -# 3. Publish to staging -fastskill publish --api-url https://staging.example.com --artifacts ./artifacts/pptx-1.2.3.zip - -# 4. Promote to production -fastskill publish --api-url https://prod.example.com --artifacts ./artifacts/pptx-1.2.3.zip -``` - -## Troubleshooting - - - - - **Check registry URL**: Ensure registry is accessible. - ```bash - curl https://registry.example.com/health - ``` - - - - **Check role**: Ensure role is valid (`user`, `manager`, or `admin`). - ```bash - fastskill auth login --role manager - ``` - - - - **Check network**: Ensure network allows HTTPS requests. - ```bash - # Test connectivity - ping registry.example.com - ``` - - - - - - **Refresh token**: Logout and login again. - ```bash - fastskill auth logout - fastskill auth login --role manager - ``` - - - - **Check expiration**: - ```bash - fastskill auth whoami - # Look for "Token expires: ..." field - ``` - - - - - - **Check role**: Ensure your role has required permissions. - - Publishing requires `manager` or `admin` - - Admin operations require `admin` - - - - **Check registry configuration**: Some registries may have custom permissions. - ```bash - fastskill auth whoami - ``` - - - - - - **Specify registry explicitly**: Use `--api-url` for registry API URLs or `--registry` for configured repository names. - ```bash - fastskill publish --api-url https://prod.example.com --artifacts ./artifacts/pptx.zip - ``` - - - - **Check auth status**: - ```bash - fastskill auth whoami --registry https://prod.example.com - fastskill auth whoami --registry https://staging.example.com - ``` - - - - -## Security Best Practices - - - -Assign minimal required role: -- Use `user` for read-only access -- Use `manager` for publishing -- Use `admin` only for registry administration - - - -Logout and login periodically: -```bash -fastskill auth logout -fastskill auth login --role manager -``` - - - -- Never commit `auth.toml` to version control -- Use environment variables for CI/CD -- Restrict file permissions on `~/.fastskill/` -```bash -chmod 700 ~/.fastskill -chmod 600 ~/.fastskill/auth.toml -``` - - - -Always use HTTPS for registry URLs: -```bash -# Good -fastskill auth login --registry https://registry.example.com - -# Bad - insecure -fastskill auth login --registry http://registry.example.com -``` - - - -Regularly check `fastskill auth whoami` and review: -- Last login times -- Token expiration -- Unusual activity - - - - -Authentication tokens are valuable credentials. Treat them like passwords and follow security best practices. - - -## See Also - -- [Registry Overview](/registry/overview) - Understanding registries -- [Publish Command](/cli-reference/publish-command) - Optional registry publishing -- [repos Command](/cli-reference/repository-command) - Managing repositories diff --git a/webdocs/cli-reference/eval-command.mdx b/webdocs/cli-reference/eval-command.mdx index 56861734..3ce9a34e 100644 --- a/webdocs/cli-reference/eval-command.mdx +++ b/webdocs/cli-reference/eval-command.mdx @@ -88,4 +88,4 @@ fastskill eval score --run-dir /tmp/evals/run-1 --no-fail ## See also -- [init Command](/cli-reference/init-command) and [package Command](/cli-reference/package-command) for skill layout and publishing +- [init Command](/cli-reference/init-command) for skill layout diff --git a/webdocs/cli-reference/init-command.mdx b/webdocs/cli-reference/init-command.mdx index 97e0306f..445f7cc5 100644 --- a/webdocs/cli-reference/init-command.mdx +++ b/webdocs/cli-reference/init-command.mdx @@ -319,6 +319,4 @@ Use `fastskill init --yes` to: ## See Also - [Install Command](/cli-reference/install-command) - Install skills from skill-project.toml -- [Package Command](/cli-reference/package-command) - Package skills for distribution -- [Publish Command](/cli-reference/publish-command) - Optional registry publishing for `registry-publish` builds - [Installation Guide](/installation) - Environment setup and prerequisites diff --git a/webdocs/cli-reference/overview.mdx b/webdocs/cli-reference/overview.mdx index 5cd0287c..36219d03 100644 --- a/webdocs/cli-reference/overview.mdx +++ b/webdocs/cli-reference/overview.mdx @@ -1,11 +1,11 @@ --- title: "CLI Reference Overview" -description: "CLI for the FastSkill package manager: installs, manifests, validation, evals, search, package, and serve." +description: "CLI for the FastSkill package manager: installs, manifests, validation, evals, search, and serve." --- ## Overview -The FastSkill CLI is how you **install and operate** skills: manifests and `skills.lock`, validation and `fastskill eval`, search, packaging, plus `serve` when you want a local HTTP surface. +The FastSkill CLI is how you **install and operate** skills: manifests and `skills.lock`, validation and `fastskill eval`, search, plus `serve` when you want a local HTTP surface. The CLI supports both interactive and scripting use cases, with comprehensive help text and validation for all commands. @@ -102,20 +102,6 @@ fastskill list --help # Help for list command Manage repositories (list/add/remove/info/update/test/refresh) and browse remote skill catalog with authentication and priority. See [repos Command](/cli-reference/repository-command). - - Manage authentication (login/logout/whoami) with JWT tokens and RBAC. See [auth Command](/cli-reference/auth-command). - - - -### Packaging - - - - Package skills into ZIP artifacts with change detection and version bumps. See [package Command](/cli-reference/package-command). - - - Optional operator command for registry publishing. It is available only in builds compiled with `registry-publish`. See [publish Command](/cli-reference/publish-command). - ### Diagnostics and tooling @@ -189,20 +175,7 @@ index_url = "https://api.fastskill.io/index" priority = 0 ``` -For opt-in registry publishing builds, create `.fastskill/publish.toml`: - -```toml .fastskill/publish.toml -[blob_storage] -type = "s3" -endpoint = "https://s3.example.com" -bucket = "skills-registry" -region = "us-east-1" - -[registry] -git_url = "https://github.com/org/skill-registry.git" -branch = "main" -blob_base_url = "https://blob.example.com/skills" -``` +For opt-in registry publishing builds, see the platform-managed deploy workflow. Skill distribution is handled by the platform operator rather than a self-hosted CLI publish path. ### Environment Variables @@ -286,7 +259,7 @@ fastskill reindex fastskill serve --port 8080 ``` -For running `fastskill` in automation (build machines, sandboxes), install the CLI in the job image, check out your repo, then run the same commands with secrets injected for registry tokens and `OPENAI_API_KEY` when needed. See [CI/CD integration](/integration/cicd-pipelines) for patterns without repository-specific paths. +For running `fastskill` in automation (build machines, sandboxes), install the CLI in the job image, check out your repo, then run the same commands with secrets injected for registry tokens and `OPENAI_API_KEY` when needed. ## Troubleshooting diff --git a/webdocs/cli-reference/package-command.mdx b/webdocs/cli-reference/package-command.mdx deleted file mode 100644 index b2953efc..00000000 --- a/webdocs/cli-reference/package-command.mdx +++ /dev/null @@ -1,281 +0,0 @@ ---- -title: package -api: "package" ---- - -# package Command - -Package skills into ZIP artifacts with automatic version bumping and change detection. This command is designed for CI/CD pipelines and local development workflows. - -## Usage - -```bash -fastskill package [OPTIONS] -``` - -## Options - -| Option | Description | Default | -|--------|-------------|---------| -| `--detect-changes` | Auto-detect changed skills using file hash comparison | `false` | -| `--git-diff ` | Use git diff for change detection | None | -| `--skills ` | Package specific skills by ID | None | -| `--bump ` | Bump version type | None | -| `--auto-bump` | Auto-detect bump type from changes (defaults to patch) | `false` | -| `--output ` | Output directory for artifacts | `./artifacts` | -| `--force` | Package all skills regardless of changes | `false` | -| `--dry-run` | Show what would be packaged without actually packaging | `false` | -| `--skills-dir ` | Skills directory to scan | `./skills` | -| `--recursive` | Recursively scan skills directory for nested skills | `false` | - -## Examples - -### Auto-detect Changed Skills - -Detect changed skills using file hash comparison and package them: - -```bash -fastskill package --detect-changes --output ./artifacts -``` - -### Git-based Change Detection - -Use git diff to detect changed skills between two commits: - -```bash -fastskill package --git-diff HEAD~1 HEAD --output ./artifacts -``` - -### Package Specific Skills - -Package only specific skills by ID: - -```bash -fastskill package --skills web-scraper data-processor --output ./artifacts -``` - -### Version Bumping - -Package with explicit version bumping: - -```bash -# Bump minor version -fastskill package --detect-changes --bump minor --output ./artifacts - -# Bump patch version -fastskill package --detect-changes --bump patch --output ./artifacts - -# Bump major version -fastskill package --detect-changes --bump major --output ./artifacts -``` - -### Auto-detect Bump Type - -Automatically determine bump type from changes (defaults to patch): - -```bash -fastskill package --detect-changes --auto-bump --output ./artifacts -``` - -### Force Package All Skills - -Package all skills regardless of changes: - -```bash -fastskill package --force --output ./artifacts -``` - -### Recursive Package Discovery - -Package all skills recursively from nested directory structures: - -```bash -# Package all skills, including nested ones -fastskill package --force --recursive --output ./artifacts -``` - -When using `--recursive`, the command scans the `--skills-dir` directory recursively and treats any directory containing `SKILL.md` as a skill. Nested skills will have IDs based on their relative path from `skills_dir` (e.g., `category1/skill-a`, `category2/sub/skill-b`). - -**Example directory structure:** - -``` -skills/ -├── top-level-skill/ -│ └── SKILL.md -├── category1/ -│ └── nested-skill/ -│ └── SKILL.md -└── category2/ - └── sub/ - └── deep-skill/ - └── SKILL.md -``` - -Running `fastskill package --force --recursive --skills-dir ./skills` will discover: -- `top-level-skill` -- `category1/nested-skill` -- `category2/sub/deep-skill` - -Without `--recursive`, only `top-level-skill` would be discovered. - -### Dry Run - -Show what would be packaged without actually creating artifacts: - -```bash -fastskill package --detect-changes --dry-run -``` - -## How It Works - -The `package` command: - -1. **Detects Changed Skills**: Uses either git diff or file hash comparison to identify which skills have changed -2. **Reads Current Version**: Extracts version from `skill-project.toml` (or defaults to "1.0.0") -3. **Bumps Version** (if requested): Updates version based on bump type (major, minor, patch) -4. **Updates skill-project.toml**: Writes new version to `skill-project.toml` file -5. **Creates ZIP Artifact**: Packages skill directory into ZIP file with: - - All skill files (SKILL.md, scripts/, references/, assets/) - - BUILD_INFO.json (build metadata) - - CHECKSUM.sha256 (file checksum) -6. **Updates Build Cache**: Records version, hash, and artifact path in `.fastskill/build-cache.json` - -## Artifact Structure - -Each packaged skill creates a ZIP file with the following structure: - -``` -skill-id-version.zip -├── SKILL.md -├── skill-project.toml -├── scripts/ -├── references/ -├── assets/ -├── BUILD_INFO.json -└── CHECKSUM.sha256 -``` - -## Build Cache - -The command maintains a build cache at `.fastskill/build-cache.json` that tracks: - -- Skill versions -- File hashes (SHA256) -- Artifact paths -- Git commit information -- Last packaged timestamp - -This cache enables efficient change detection on subsequent runs. - -## Change Detection Methods - -### File Hash-based Detection - -Uses SHA256 hashing of all files in skill directories: - -```bash -fastskill package --detect-changes -``` - -Compares current file hashes with cached hashes to detect changes. - -**Examples:** - -```bash -# Package only skills that have changed since last build -fastskill package --detect-changes --output ./artifacts - -# Combine with auto-bump for patch releases -fastskill package --detect-changes --auto-bump --output ./artifacts - -# Force rebuild all skills (ignores change detection) -fastskill package --force --output ./artifacts -``` - -**What gets hashed:** -- All files in skill directories (SKILL.md, scripts/, references/, assets/) -- Excludes: build artifacts, temp files, .git directories -- Includes: configuration files, source code, documentation - -### Git Diff-based Detection - -Uses git diff to detect changed skill directories: - -```bash -fastskill package --git-diff HEAD~1 HEAD -``` - -More efficient for large repositories with many skills, especially in CI/CD pipelines. - -**Examples:** - -```bash -# Package skills changed in the last commit -fastskill package --git-diff HEAD~1 HEAD --output ./artifacts - -# Package skills changed between two specific commits -fastskill package --git-diff abc123 def456 --output ./artifacts - -# Package skills changed since last tag -fastskill package --git-diff v1.2.0 HEAD --bump minor --output ./artifacts - -# Package skills changed in current branch vs main -fastskill package --git-diff origin/main HEAD --output ./artifacts -``` - -**Change Detection Logic:** -- Identifies skill directories with modified files -- Considers additions, deletions, and modifications -- Ignores non-skill file changes (README.md, CI config, etc.) -- Supports any valid git revision range - -**Advanced Scenarios:** - -```bash -# CI/CD: Package only skills changed in PR -fastskill package \ - --git-diff origin/main HEAD \ - --bump patch \ - --output ./artifacts - -# Release management: Major version bump for breaking changes -fastskill package \ - --git-diff v1.0.0 v2.0.0 \ - --bump major \ - --output ./artifacts - -# Hotfix: Patch release for specific commits -fastskill package \ - --git-diff hotfix-start hotfix-end \ - --bump patch \ - --output ./artifacts -``` - -## Version Bumping - -Version bumping follows semantic versioning (semver): - -- **Major**: Breaking changes (1.2.3 → 2.0.0) -- **Minor**: New features (1.2.3 → 1.3.0) -- **Patch**: Bug fixes (1.2.3 → 1.2.4) - -The `--auto-bump` flag currently defaults to patch bumps. Future versions may analyze changes to determine appropriate bump type. - -## CI/CD Integration - -The package command is designed for CI/CD workflows: - -```yaml -# GitHub Actions example -- name: Package changed skills - run: | - fastskill package \ - --git-diff HEAD~1 HEAD \ - --auto-bump \ - --output ./artifacts -``` - -## See Also - -- [Publish Command](/cli-reference/publish-command) - optional `registry-publish` builds only -- [CI/CD Integration Guide](/integration/cicd-pipelines) diff --git a/webdocs/cli-reference/publish-command.mdx b/webdocs/cli-reference/publish-command.mdx deleted file mode 100644 index d67a7538..00000000 --- a/webdocs/cli-reference/publish-command.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "publish (optional)" -api: "publish" ---- - -# publish Command - -Publish skill packages to a registry API or local folder. - - -`fastskill publish` is an optional operator command. Public/default builds do not include it. Build or install FastSkill with the `registry-publish` feature when you need this command. - - -## Usage - -```bash -fastskill publish [OPTIONS] -``` - -## Options - -| Option | Description | Default | -|--------|-------------|---------| -| `--artifacts ` | Package file or directory containing ZIP artifacts | `./artifacts` | -| `--registry ` | Repository name from `repositories.toml` | none | -| `--api-url ` | Registry API base URL, for example `https://registry.example.com` | `FASTSKILL_API_URL` if no target flag is set | -| `--local-dir ` | Local directory to copy ZIP artifacts into | none | -| `--wait ` | Wait for validation to complete in API mode | `true` | -| `--max-wait ` | Maximum wait time in seconds | `300` | - -`--registry`, `--api-url`, and `--local-dir` are mutually exclusive. - -## Examples - -### Publish to Local `fastskill serve` - -```bash -fastskill publish \ - --artifacts ./artifacts \ - --api-url http://localhost:8080 -``` - -Local `fastskill serve` publish endpoints do not require auth headers. - -### Publish to Remote Registry - -```bash -fastskill auth login --registry https://registry.example.com -fastskill publish \ - --artifacts ./artifacts \ - --api-url https://registry.example.com -``` - -Remote registries may require authentication depending on server configuration. - -### Publish Through a Configured Repository - -```bash -fastskill publish \ - --artifacts ./artifacts \ - --registry official -``` - -### Publish to Local Folder - -```bash -fastskill publish \ - --artifacts ./artifacts \ - --local-dir ./local-registry -``` - -## Local Publish Identity - -When publishing to local `fastskill serve`, identity is deterministic and anonymous: - -- `uploaded_by = "anonymous"` -- publish scope = `anonymous` -- returned skill id format = `anonymous/` - -## API Mode Workflow - -1. Upload package using multipart form data -2. Package is staged for validation -3. Check status with `/api/v1/registry/publish/status/:job_id` -4. Accepted packages are written to configured outputs - -## Troubleshooting - -- **"No artifacts found"**: Verify `--artifacts` path points to a ZIP file or directory containing ZIP files -- **"Publish timeout"**: Increase `--max-wait` or set `--wait false` -- **"Registry unavailable"**: Check `--api-url` URL and network connectivity - -## See Also - -- [serve Command](/cli-reference/serve-command) -- [auth Command](/cli-reference/auth-command) diff --git a/webdocs/cli-reference/reindex-command.mdx b/webdocs/cli-reference/reindex-command.mdx index 77989f2b..a9b3767f 100644 --- a/webdocs/cli-reference/reindex-command.mdx +++ b/webdocs/cli-reference/reindex-command.mdx @@ -429,7 +429,7 @@ fastskill reindex --skills-dir ./custom-skills/ ### Automated reindex -After installs in automation, run `fastskill reindex` with the same environment variables you use locally (`OPENAI_API_KEY`, optional `--skills-dir`). See [CI/CD integration](/integration/cicd-pipelines) for runner patterns. +After installs in automation, run `fastskill reindex` with the same environment variables you use locally (`OPENAI_API_KEY`, optional `--skills-dir`). ## Related Commands diff --git a/webdocs/cli-reference/serve-command.mdx b/webdocs/cli-reference/serve-command.mdx index 57079304..3ba01403 100644 --- a/webdocs/cli-reference/serve-command.mdx +++ b/webdocs/cli-reference/serve-command.mdx @@ -40,7 +40,6 @@ fastskill serve --host 0.0.0.0 --port 3000 - No token endpoint is exposed - API routes do not require `Authorization` or `x-api-key` headers -- Registry publish routes, when compiled with the `registry-publish` feature, use anonymous identity (`anonymous/`) If you expose the server on a shared or untrusted network, use network controls or a reverse proxy. @@ -73,8 +72,7 @@ Sending `SIGINT` (Ctrl-C) or `SIGTERM` causes the server to: 1. Flip `/readyz` to HTTP 503 so load balancers stop routing new traffic. 2. Drain in-flight requests to completion. -3. Stop the background `ValidationWorker` when the server is built with `registry-publish` and registry publishing is configured. -4. Exit cleanly with exit code 0. +3. Exit cleanly with exit code 0. ## Core Endpoints @@ -95,15 +93,6 @@ Sending `SIGINT` (Ctrl-C) or `SIGTERM` causes the server to: | `/healthz` | GET | Liveness probe | | `/readyz` | GET | Readiness probe | -## Optional Registry Publish Endpoints - -These endpoints exist only when FastSkill is built with the `registry-publish` feature: - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/registry/publish` | POST | Publish package with anonymous local identity | -| `/api/v1/registry/publish/status/{job_id}` | GET | Publish job status | - ## Redirect Behavior `GET /api/skills` → HTTP 308 → `GET /api/v1/skills` (and similarly for all `/api/…` paths). @@ -111,4 +100,3 @@ These endpoints exist only when FastSkill is built with the `registry-publish` f ## See Also - [CLI Reference Overview](/cli-reference/overview) -- [publish Command](/cli-reference/publish-command) diff --git a/webdocs/cli-reference/update-command.mdx b/webdocs/cli-reference/update-command.mdx index 133e9b6e..38a9e04e 100644 --- a/webdocs/cli-reference/update-command.mdx +++ b/webdocs/cli-reference/update-command.mdx @@ -523,4 +523,3 @@ Updating data-processor... - [Install Command](/cli-reference/install-command) - Install skills from skill-project.toml - [Add Command](/cli-reference/skill-commands#add) - Add skills to project - [List Command](/cli-reference/skill-commands#list) - List installed skills and versions -- [Package Command](/cli-reference/package-command) - Package skills with versioning diff --git a/webdocs/index.mdx b/webdocs/index.mdx index 05c58ab5..8d7d897a 100644 --- a/webdocs/index.mdx +++ b/webdocs/index.mdx @@ -13,7 +13,7 @@ New here? Read the [Welcome](/welcome) page for the full story, then [Quick star ## What is FastSkill? -**Package manager and operational toolkit for Agent AI Skills.** FastSkill enables discovery, installation, versioning, and deployment of skills at scale. It implements the same `SKILL.md` conventions used by Claude Code–compatible agents, and adds **manifests**, **lockfiles**, **validation**, optional **`fastskill eval`** runs, **search**, and **publish** flows when you ship skills to others. Modern agents read installed skills directly from the skills directory — no metadata-file sync step. +**Package manager and operational toolkit for Agent AI Skills.** FastSkill enables discovery, installation, versioning, and deployment of skills at scale. It implements the same `SKILL.md` conventions used by Claude Code–compatible agents, and adds **manifests**, **lockfiles**, **validation**, optional **`fastskill eval`** runs, and **search**. Modern agents read installed skills directly from the skills directory — no metadata-file sync step. The project is designed around a **CLI-first workflow**; local **`fastskill serve`** exposes an HTTP API and web UI for browsing and integration. @@ -40,8 +40,8 @@ The project is designed around a **CLI-first workflow**; local **`fastskill serv `fastskill doctor` reports configuration and environment readiness, including whether semantic search is available. See [tooling commands](/cli-reference/tooling-commands). - - `package` and `publish` when you distribute; `repos` for remote sources. See [Registry overview](/registry/overview). + + `repos` for remote sources and registry browsing. See [Registry overview](/registry/overview). @@ -60,15 +60,15 @@ Then optional: `fastskill reindex`, `fastskill search "…" --local`, `fastskill ## How it fits together - **You**: edit `SKILL.md` and manifests; agents **read** skill content. FastSkill does not execute skill logic as a runtime sandbox. -- **CLI**: single entry point for installs, checks, search, evals, and packaging. +- **CLI**: single entry point for installs, checks, search, and evals. - **Optional `serve`**: HTTP API and UI on your machine for browsing and integrations. ## Use cases (summary) -- **Authors**: init, validate, eval, and package when ready; publish requires an operator build with `registry-publish`. +- **Authors**: init, validate, eval, and ship skills via the platform. - **Developers**: install, list, read, search for what agents load. - **Teams**: shared manifest + lock; groups for optional stacks. -- **Automation**: `install --lock`, package, and optional publish in CI (patterns in [CI/CD](/integration/cicd-pipelines)). +- **Automation**: `install --lock` and reproducible installs in CI. ## Next steps @@ -83,7 +83,7 @@ Then optional: `fastskill reindex`, `fastskill search "…" --local`, `fastskill [Skill validation](/skill-management/validation), [Evals and quality](/evals-quality/overview), [CLI reference](/cli-reference/overview). - [Cursor integration](/integration/cursor-integration) or [CI/CD](/integration/cicd-pipelines). + [Cursor integration](/integration/cursor-integration) or [Claude Code integration](/integration/claude-code-integration). diff --git a/webdocs/integration/blob-storage.mdx b/webdocs/integration/blob-storage.mdx deleted file mode 100644 index 979b17f1..00000000 --- a/webdocs/integration/blob-storage.mdx +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: "Blob Storage Integration" -description: "Configure registry artifact storage for opt-in publish builds." ---- - -# Blob Storage Integration - -Blob storage is an operator-side concern for registry servers. Public/default FastSkill builds do not include registry publishing, and `fastskill publish` does not expose direct S3 flags. - -Use this page only when running a FastSkill build compiled with the `registry-publish` feature. - -## Model - -FastSkill separates packaging from publishing: - -1. `fastskill package` creates ZIP artifacts. -2. `fastskill publish` sends those ZIPs to a registry API or copies them to a local directory. -3. A registry server built with `registry-publish` validates accepted packages, writes artifacts to configured blob storage, and updates the registry index. - -## CLI Targets - -```bash -# Publish to a registry API -fastskill publish --artifacts ./artifacts --api-url https://registry.example.com - -# Publish through a configured repository name -fastskill publish --artifacts ./artifacts --registry official - -# Copy artifacts to a local directory -fastskill publish --artifacts ./artifacts --local-dir ./upload -``` - -## Server Storage - -Configure blob storage in the registry server's service configuration. S3 and S3-compatible storage require the `registry-publish` feature. - -Typical fields are: - -- storage type -- bucket -- region -- endpoint for S3-compatible services -- access key and secret key, usually sourced from deployment secrets -- public blob base URL for generated download links - -Keep credentials out of repository files. Inject them through your deployment platform or secret manager. - -## Workflow - -```bash -# 1. Package skills -fastskill package --detect-changes --auto-bump --output ./artifacts - -# 2. Publish to the registry API -fastskill publish --artifacts ./artifacts --api-url https://registry.example.com - -# 3. Poll the job status if needed -curl https://registry.example.com/api/v1/registry/publish/status/{job_id} -``` - -## CI Example - -```yaml -jobs: - publish-skills: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Package - run: fastskill package --git-diff HEAD~1 HEAD --auto-bump --output ./artifacts - - name: Publish - run: fastskill publish --artifacts ./artifacts --api-url "${FASTSKILL_API_URL}" - env: - FASTSKILL_API_URL: ${{ secrets.FASTSKILL_API_URL }} - FASTSKILL_API_TOKEN: ${{ secrets.FASTSKILL_API_TOKEN }} -``` - -## See Also - -- [Publish Command](/cli-reference/publish-command) -- [Registry Index System](/registry/index-system) -- [CI/CD Pipelines](/integration/cicd-pipelines) diff --git a/webdocs/integration/cicd-pipelines.mdx b/webdocs/integration/cicd-pipelines.mdx deleted file mode 100644 index 806e6c1a..00000000 --- a/webdocs/integration/cicd-pipelines.mdx +++ /dev/null @@ -1,235 +0,0 @@ ---- -title: "CI/CD Pipeline Integration" -description: "Use FastSkill in automated pipelines to package skills and optionally publish them with reproducible installs." ---- - -# CI/CD Pipeline Integration - -This page describes how teams typically wire `fastskill` into automation: detect changes, package ZIPs, and optionally publish to a registry. Publishing requires a FastSkill build compiled with the `registry-publish` feature. - -## What you can automate - -- **Automatic Change Detection**: Detect which skills have changed using git diff or file hashing -- **Version Bumping**: Automatically increment semantic versions (major, minor, patch) -- **Artifact Creation**: Package skills into ZIP files with metadata and checksums -- **Optional Registry Publishing**: Send packaged artifacts to a registry API or local handoff directory -- **Registry Index Management**: Maintain a registry index for skill distribution in operator builds - -## Change Detection Strategies - -### Git-based Detection - -Use git diff to detect changed skill directories. Common in hosted git runners: - -```bash -fastskill package --git-diff HEAD~1 HEAD --auto-bump --output ./artifacts -``` - -**Advantages**: -- Fast and efficient -- Works well in CI/CD environments -- Can compare any two git references (commits, tags, branches) - -**Use Cases**: -- GitHub Actions workflows -- GitLab CI pipelines -- Any git-based CI/CD system - -### Hash-based Detection - -Use file hashing to detect changes when you prefer not to rely on git metadata: - -```bash -fastskill package --detect-changes --auto-bump --output ./artifacts -``` - -**Advantages**: -- Works without git repository -- Detects any file changes -- Maintains build cache for efficiency - -**Typical uses**: -- Sandboxes without full git history -- Incremental packaging on a workstation - -## Version Bumping Strategies - -### Explicit Bump Type - -Specify the bump type explicitly: - -```bash -# Major version (breaking changes) -fastskill package --detect-changes --bump major - -# Minor version (new features) -fastskill package --detect-changes --bump minor - -# Patch version (bug fixes) -fastskill package --detect-changes --bump patch -``` - -### Auto-detect Bump Type - -Let FastSkill determine the bump type (currently defaults to patch): - -```bash -fastskill package --detect-changes --auto-bump -``` - -Future versions may analyze commit messages or change types to determine appropriate bump level. - -## Complete Workflow Examples - -### Local packaging loop - -```bash -fastskill package --detect-changes --auto-bump --output ./artifacts -fastskill publish --registry official # requires registry-publish; or use --api-url / --local-dir -``` - -### Example: hosted runner (outline) - -1. Check out your repository with enough history for `fastskill package --git-diff `. -2. Install the `fastskill` binary using the same method you use on laptops. Use an operator build with `registry-publish` if the job publishes artifacts. -3. Restore secrets for registries (`FASTSKILL_API_URL`, tokens, cloud credentials) as environment variables. -4. Run `fastskill package …` then `fastskill publish …` with the flags from [package](/cli-reference/package-command) and [publish](/cli-reference/publish-command). - -```yaml -# Illustrative fragment only — adjust names, triggers, and secrets to your org -jobs: - skills: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Install FastSkill CLI - run: | - # Use your standard install path (release tarball, package, or internal mirror) - fastskill -V - - name: Package changed skills - run: fastskill package --git-diff HEAD~1 HEAD --auto-bump --output ./artifacts - - name: Publish - run: fastskill publish --local-dir ./upload - env: - FASTSKILL_API_URL: ${{ secrets.FASTSKILL_API_URL }} -``` - -### Other runners - -GitLab CI, Jenkins, Buildkite, and others follow the same outline: put `fastskill` on `PATH`, check out sources, run `package`, then run `publish` only in jobs that use a `registry-publish` build. Copy the CLI arguments from `fastskill package --help` and `fastskill publish --help` rather than relying on historical workflow snippets. - -```groovy -// Jenkins-style pseudocode — replace install and credentials with yours -pipeline { - agent any - - environment { - AWS_ACCESS_KEY_ID = credentials('aws-access-key-id') - AWS_SECRET_ACCESS_KEY = credentials('aws-secret-access-key') - } - - stages { - stage('Package') { - steps { - sh ''' - fastskill package \ - --git-diff HEAD~1 HEAD \ - --auto-bump \ - --output ./artifacts - ''' - } - } - - stage('Publish') { - steps { - sh ''' - fastskill publish \ - --artifacts ./artifacts \ - --api-url "${FASTSKILL_API_URL}" - ''' - } - } - } -} -``` - -## Configuration Best Practices - -### Environment Variables - -Store sensitive credentials as environment variables or secrets: - -```bash -# AWS S3 -export AWS_ACCESS_KEY_ID=your-key -export AWS_SECRET_ACCESS_KEY=your-secret - -# Blob storage base URL -export BLOB_BASE_URL=https://blob.example.com/skills -``` - -### Configuration Files - -Use `.fastskill/publish.toml` for non-sensitive configuration: - -```toml -[blob_storage] -type = "s3" -bucket = "skills-registry" -region = "us-east-1" -# Credentials from environment variables - -[registry] -git_url = "https://github.com/GoFastSkill/skill-registry.git" -branch = "main" -blob_base_url = "https://blob.example.com/skills" -``` - -### Build Cache - -The build cache (`.fastskill/build-cache.json`) can be persisted between pipeline runs if you want faster packaging. Treat it as a machine-local artifact: refresh or delete it when packaging results look wrong. - -## Troubleshooting - -### No Skills Detected - -If no skills are detected for packaging: - -1. Check that skills directory exists and contains `SKILL.md` files -2. Verify git diff is working: `git diff HEAD~1 HEAD --name-only` -3. Check build cache for stale entries -4. Use `--force` to package all skills regardless of changes - -### Version Bumping Issues - -If version bumping fails: - -1. Ensure `skill-project.toml` exists or can be created -2. Check that version format is valid semver (e.g., "1.2.3") -3. Verify write permissions for `skill-project.toml` - -### Blob Storage Upload Failures - -If uploads fail: - -1. Verify credentials are correct -2. Check network connectivity to storage endpoint -3. Ensure bucket/container exists and is accessible -4. Verify IAM permissions for S3 buckets - -### Registry Index Issues - -If registry index operations fail: - -1. Ensure git repository is accessible -2. Check git credentials and permissions -3. Verify registry path exists and is writable -4. Ensure blob base URL is correct - -## See Also - -- [Package Command](/cli-reference/package-command) -- [Publish Command](/cli-reference/publish-command) - optional `registry-publish` builds only -- [Blob Storage Guide](/integration/blob-storage) diff --git a/webdocs/mint.json b/webdocs/mint.json index 250462dc..f0fcd99b 100644 --- a/webdocs/mint.json +++ b/webdocs/mint.json @@ -77,12 +77,9 @@ "cli-reference/overview", "cli-reference/skill-commands", "cli-reference/discovery-commands", - "cli-reference/auth-command", "cli-reference/init-command", "cli-reference/install-command", "cli-reference/update-command", - "cli-reference/package-command", - "cli-reference/publish-command", "cli-reference/reindex-command", "cli-reference/search-command", "cli-reference/serve-command", @@ -113,9 +110,7 @@ "group": "Integration", "pages": [ "integration/cursor-integration", - "integration/claude-code-integration", - "integration/cicd-pipelines", - "integration/blob-storage" + "integration/claude-code-integration" ] } ], diff --git a/webdocs/quickstart.mdx b/webdocs/quickstart.mdx index 4926898d..63c077a5 100644 --- a/webdocs/quickstart.mdx +++ b/webdocs/quickstart.mdx @@ -107,11 +107,6 @@ The HTTP API is available under `/api/v1/…`, e.g.: - Extract version from SKILL.md (`metadata.version` or `version`, defaulting to `1.0.0`) - Display a warning suggesting `fastskill init` to add `skill-project.toml` -- **Package skills** - ```bash - fastskill package --skills my-skill --output ./artifacts --bump patch - ``` - ## Troubleshooting - **No skills found**: ensure `skill-project.toml` exists at project root and rerun `fastskill install`. diff --git a/webdocs/registry/index-system.mdx b/webdocs/registry/index-system.mdx index c9c2ef6f..ed1def0a 100644 --- a/webdocs/registry/index-system.mdx +++ b/webdocs/registry/index-system.mdx @@ -1,15 +1,15 @@ --- title: "Registry Index System" -description: "Understanding the HTTP-based registry index system with flat layout for skill distribution" +description: "Understanding the on-disk registry index format read by fastskill serve and registry search" --- # Registry Index System -FastSkill uses a simple organization/package directory structure for skill distribution. This provides an intuitive approach to organizing skills by their publishing organization. +FastSkill uses a simple organization/package directory structure for skill distribution. Each skill's published versions are stored as newline-delimited JSON in a single file. ## Overview -The registry index is maintained automatically during the publishing process. Each skill is stored in a directory structure based on its organization and package name. +The registry index is an on-disk NDJSON catalog read by `fastskill serve` and the registry search endpoints. It is populated externally (for example, by the platform operator that hosts the registry) rather than by a self-hosted CLI write path. ## Registry Structure @@ -31,13 +31,13 @@ registry-index/ The directory structure is derived from the skill ID (scope/id format): -- **Scope** → First directory level (from JWT token during publishing, e.g., "acme") -- **ID** → Second directory level (from skill-project.toml, must not contain slashes, e.g., "web-scraper") +- **Scope** → First directory level (the publishing organization, e.g., "acme") +- **ID** → Second directory level (the package name, must not contain slashes, e.g., "web-scraper") Examples: -- `acme/web-scraper` → `acme/web-scraper/` (scope: "acme", id: "web-scraper" from skill-project.toml) -- `myorg/analytics-tool` → `myorg/analytics-tool/` (scope: "myorg", id: "analytics-tool" from skill-project.toml) -- `john/personal-helper` → `john/personal-helper/` (scope: "john", id: "personal-helper" from skill-project.toml) +- `acme/web-scraper` → `acme/web-scraper/` (scope: "acme", id: "web-scraper") +- `myorg/analytics-tool` → `myorg/analytics-tool/` (scope: "myorg", id: "analytics-tool") +- `john/personal-helper` → `john/personal-helper/` (scope: "john", id: "personal-helper") This structure: - Groups skills by organization @@ -47,35 +47,15 @@ This structure: ## Version Metadata Format -Each skill has a single file at `{scope}/{id}` containing newline-delimited JSON entries for all published versions, sorted with newest first. The path is constructed from: -- `scope`: Extracted from JWT token during publishing (e.g., "acme") -- `id`: From skill-project.toml (must not contain slashes, e.g., "web-scraper") +Each skill has a single file at `{scope}/{id}` containing newline-delimited JSON entries for all versions. The path is constructed from: +- `scope`: The publishing organization (e.g., "acme") +- `id`: The package name from `skill-project.toml` (must not contain slashes, e.g., "web-scraper") + +Each line is a compact `VersionEntry` JSON object: ```json -[ - { - "name": "web-scraper", - "vers": "1.2.3", - "deps": [], - "cksum": "sha256:abc123def456...", - "features": {}, - "yanked": false, - "links": null, - "download_url": "https://blob.example.com/skills/web-scraper-1.2.3.zip", - "published_at": "2024-01-01T12:00:00Z" - }, - { - "name": "web-scraper", - "vers": "1.2.2", - "deps": [], - "cksum": "sha256:def456ghi789...", - "features": {}, - "yanked": false, - "links": null, - "download_url": "https://blob.example.com/skills/web-scraper-1.2.2.zip", - "published_at": "2024-01-01T10:00:00Z" - } -] +{"name":"web-scraper","vers":"1.2.3","deps":[],"cksum":"sha256:abc123def456...","features":{},"yanked":false,"links":null,"download_url":"https://blob.example.com/skills/web-scraper-1.2.3.zip","published_at":"2024-01-01T12:00:00Z"} +{"name":"web-scraper","vers":"1.2.2","deps":[],"cksum":"sha256:def456ghi789...","features":{},"yanked":false,"links":null,"download_url":"https://blob.example.com/skills/web-scraper-1.2.2.zip","published_at":"2024-01-01T10:00:00Z"} ``` ### Field Descriptions @@ -90,31 +70,6 @@ Each skill has a single file at `{scope}/{id}` containing newline-delimited JSON - **download_url**: URL to download the ZIP artifact - **published_at**: ISO 8601 timestamp of publication -## Automatic Index Management - -The registry index is maintained automatically during the publishing process. No manual index management is required. - -Publishing requires a FastSkill build compiled with the `registry-publish` feature. - -### Publishing Workflow - -```bash -# 1. Package skills -fastskill package --detect-changes --auto-bump --output ./artifacts - -# 2. Publish to registry (automatically updates index and uploads to blob storage) -fastskill publish --artifacts ./artifacts --registry official -``` - -### How It Works - -When you publish a skill: - -1. The skill is validated and uploaded to blob storage -2. The registry index is automatically updated with the new skill metadata -3. The index maintains a complete history of all versions -4. Skills are organized by organization/package structure - ## Accessing the Registry ### HTTP Endpoint @@ -134,9 +89,15 @@ curl https://api.fastskill.io/index/dev-user/web-scraper This returns the newline-delimited JSON entries for all versions of that skill. +A browse/search surface is also available: + +``` +GET /registry/index/skills +``` + ### From FastSkill CLI -Skills published to registries can be installed using the standard FastSkill commands: +Skills in a registry can be installed using the standard FastSkill commands: ```bash # Install a skill by its scope/package name @@ -146,42 +107,8 @@ fastskill add dev-user/web-scraper fastskill add dev-user/web-scraper@1.2.3 ``` -### Registry Structure - -The registry maintains a flat filesystem structure on the server that groups skills by scope: - -``` -registry-index/ -├── dev-user/ -│ └── web-scraper -├── acme/ -│ └── data-processor -└── myorg/ - └── analytics-tool -``` - -Each file path directly matches the skill ID format (`{scope}/{skill-name}`), making lookups efficient. This structure provides: -- Intuitive organization browsing -- Efficient skill lookup by scope/package name -- Direct HTTP access to index files -- Scalable storage for growing registries - ## Version Management -### Adding New Versions - -New versions are automatically added when you publish skills: - -```bash -fastskill publish --artifacts ./artifacts --registry official -``` - -The publishing process: -1. Validates the skill package -2. Uploads to blob storage -3. Updates the registry index with version metadata -4. Maintains complete version history - ### Version History All versions are preserved in the registry index. This provides: @@ -191,48 +118,27 @@ All versions are preserved in the registry index. This provides: ### Skill Organization -Skills are always published with an organization prefix (from the authenticated user): +Skills are always stored with an organization prefix: - Format: `{org}/{package}` -- Organization: Authenticated username (lowercase, filesystem-safe) +- Organization: The publishing organization (lowercase, filesystem-safe) - Package: Skill package name - Example: `john/my-tool`, `acme/web-scraper` ## Best Practices -### Publishing - -1. **Version Consistency**: Ensure version numbers follow semver -2. **Authentication**: Always authenticate before publishing -3. **Organization**: Skills are automatically scoped to your username -4. **Testing**: Test skills before publishing to registries - ### Registry Management -1. **Automatic Updates**: Registry index is updated automatically during publishing -2. **Version History**: All versions are preserved for audit and rollback -3. **Organization Structure**: Skills are organized by publishing organization -4. **Blob Storage**: Artifacts are stored securely with checksum verification +1. **Version History**: All versions are preserved for audit and rollback +2. **Organization Structure**: Skills are organized by publishing organization +3. **Blob Storage**: Artifacts are stored securely with checksum verification ### Security -1. **Authentication**: Use proper authentication for publishing -2. **Checksum Verification**: All downloads include SHA256 checksums -3. **Access Control**: Registry access is controlled by authentication -4. **Audit Trail**: Publishing history is maintained automatically - -## Integration with Blob Storage - -Published skills are automatically uploaded to configured blob storage: - -- Artifacts are stored securely with unique paths -- Download URLs include checksum verification -- Blob storage configuration is set per registry -- Automatic cleanup and deduplication +1. **Checksum Verification**: All downloads include SHA256 checksums +2. **Access Control**: Registry access is controlled by authentication +3. **Audit Trail**: Publishing history is maintained automatically ## See Also -- [Package Command](/cli-reference/package-command) -- [Publish Command](/cli-reference/publish-command) - optional `registry-publish` builds only -- [Blob Storage Guide](/integration/blob-storage) - [Registry Overview](/registry/overview) diff --git a/webdocs/registry/sources.mdx b/webdocs/registry/sources.mdx index 7ea8c352..598865b7 100644 --- a/webdocs/registry/sources.mdx +++ b/webdocs/registry/sources.mdx @@ -218,11 +218,8 @@ fastskill serve --port 8080 **2. Authentication Setup** ```bash -# Login to registry -fastskill auth login - -# Or set token directly -export FASTSKILL_API_TOKEN="your-jwt-token" +# Set token directly for authenticated downloads +export FASTSKILL_API_TOKEN="your-token" ``` **3. Configure Repository** @@ -258,35 +255,6 @@ fastskill repos versions engineering/data-analyzer --repository company-registry fastskill add engineering/data-analyzer ``` -#### Publishing to HTTP Registry - -**Package Skills:** -```bash -# Package changed skills -fastskill package --detect-changes --output ./artifacts - -# Or package specific skills -fastskill package --skills data-analyzer web-scraper --output ./artifacts -``` - -**Publish to Registry:** -Requires a FastSkill build compiled with the `registry-publish` feature. - -```bash -# Authenticate -fastskill auth login - -# Publish packages -fastskill publish \ - --artifacts ./artifacts \ - --api-url https://registry.yourcompany.com \ - --wait true - -# Check publish status -curl -H "Authorization: Bearer $FASTSKILL_API_TOKEN" \ - https://registry.yourcompany.com/api/v1/registry/publish/status/{job_id} -``` - #### Configuration Options ```toml @@ -377,14 +345,12 @@ ZIP URL sources provide skills through static hosting with pre-packaged ZIP arch **1. Prepare Skill Packages** ```bash -# Create skills directory +# Create skills directory and place packaged skill ZIPs mkdir skills-packs cd skills-packs -# Package individual skills -fastskill package --force --output ./packages - -# This creates: web-scraper-1.0.0.zip, data-analyzer-1.0.0.zip, etc. +# Each skill must be packaged as a ZIP artifact +# (e.g. web-scraper-1.0.0.zip, data-analyzer-1.0.0.zip) ``` **2. Create Marketplace.json** @@ -520,23 +486,20 @@ The marketplace.json file must include `download_url` fields pointing to ZIP fil #### CI/CD Pipeline Example -**GitHub Actions for Automated Publishing:** +**GitHub Actions for Automated Deployment:** ```yaml -name: Publish Skills +name: Deploy Skills on: push: branches: [main] paths: ['skills/**'] jobs: - package-and-deploy: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Package skills - run: fastskill package --detect-changes --output ./packages - - name: Generate marketplace.json run: fastskill marketplace create ./skills --output ./marketplace.json @@ -743,7 +706,7 @@ vim ~/local-skills/experimental/test-skill/scripts/test.js # Test changes immediately fastskill add local-dev/test-skill --force -# No need to package or publish for local development +# No build step needed for local development ``` **Version Control Integration:** diff --git a/webdocs/skill-management/validation.mdx b/webdocs/skill-management/validation.mdx index 4ac08e18..2f13ea9e 100644 --- a/webdocs/skill-management/validation.mdx +++ b/webdocs/skill-management/validation.mdx @@ -124,7 +124,7 @@ fastskill list fastskill read --meta ``` -If you use automation, run the same commands after `fastskill install` or `fastskill install --lock` so broken skills fail the job early. See [CI/CD integration](/integration/cicd-pipelines) for runner outlines without repository-specific install paths. +If you use automation, run the same commands after `fastskill install` or `fastskill install --lock` so broken skills fail the job early. ## Best Practices diff --git a/webdocs/welcome.mdx b/webdocs/welcome.mdx index d9fc8c30..cc326598 100644 --- a/webdocs/welcome.mdx +++ b/webdocs/welcome.mdx @@ -19,7 +19,7 @@ AI agent skills are scattered across repositories with no standard way to discov ## How FastSkill Solves These Problems -FastSkill extends the Claude Code skill standard with a **manifest-first workflow**: one `skill-project.toml`, reproducible installs via `skills.lock`, validation and `fastskill eval` for quality gates, then discovery and optional operator publishing when you distribute outward. +FastSkill extends the Claude Code skill standard with a **manifest-first workflow**: one `skill-project.toml`, reproducible installs via `skills.lock`, validation and `fastskill eval` for quality gates, then discovery when you distribute outward. ### Semantic discovery @@ -240,7 +240,7 @@ fastskill search "charts and graphs" - Standard manifests and locks across projects - Auditing via list/show and validation - Environment-specific groups -- Optional private sources and operator publish flows when you distribute +- Optional private sources when you distribute ### Skill Authors @@ -276,7 +276,7 @@ FastSkill focuses on **repeatable operations** for AI agent skills: - [Manifest system](/skill-management/manifest-system) for `skill-project.toml`, locks, and how installs resolve - [CLI reference](/cli-reference/overview) and [tooling commands](/cli-reference/tooling-commands) (`doctor`, `analyze`, …) - [Cursor integration](/integration/cursor-integration) for using installed skills in the editor -- [Registry overview](/registry/overview) when you are ready to publish or browse catalogs +- [Registry overview](/registry/overview) when you are ready to browse catalogs ---