diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 1a6924a..ff39cd5 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -182,6 +182,7 @@ Ids are stable. Tests must name or comment the id they prove. | C5 | `skill check` after an installed skill's frontmatter name is corrupted | Exit ≠ 0; reports the skill-name mismatch | | C6 | `skill check` after an installed `SKILL.md` loses its YAML frontmatter | Exit ≠ 0; reports that YAML frontmatter is required | | C7 | `skill check` after an installed `SKILL.md` has unclosed frontmatter | Exit ≠ 0; reports that the frontmatter is not closed | +| C8 | `skill check` or `skill lock` after embedded `manage-tink` differs from the active binary | Exit ≠ 0; reports the drift and the exact `tink skill refresh manage-tink` repair command; lockfiles are not written | ### Project manifest @@ -259,6 +260,12 @@ Ids are stable. Tests must name or comment the id they prove. | P6 | `skill refresh` when upstream revision moves but skill tree bytes match | Exit 0; bumps project + library receipts | | P7 | `skill refresh` when project already at HEAD but library is stale | Exit 0; repairs library from project | | P8 | `skill refresh` for all when a later imported skill has local modifications | Exit ≠ 0; no project skill is updated | +| P9 | `skill refresh manage-tink` when the embedded copy is missing | Installs the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | +| P10 | `skill refresh manage-tink` when the embedded copy already matches the active binary | Exit 0 with `Unchanged`; reconciles library and catalog; project tree remains identical | +| P11 | `skill refresh manage-tink` when a receipt-free reserved copy differs from the active binary | Atomically replaces it with the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | +| P12 | `skill refresh manage-tink` when the same-named skill has remote provenance | Exit ≠ 0; reports the provenance collision; user-owned tree remains byte-identical | +| P13 | `skill refresh manage-tink` when the project copy is missing and the same-named library skill has remote provenance | Exit ≠ 0 before publication; project remains missing; library tree and receipt remain byte-identical | +| P14 | `skill refresh manage-tink` when a current or stale receipt-free project copy exists and the same-named library skill has remote provenance | Exit ≠ 0 before publication; project trees plus library tree and receipt remain byte-identical | ### Remove @@ -268,7 +275,7 @@ Ids are stable. Tests must name or comment the id they prove. | X2 | `skill remove ` | Exit ≠ 0; mentions not found / missing; nothing deleted | | X3 | `skill remove` when `.agents` is a symlink | Exit ≠ 0; mentions symlink; tree unchanged | | X4 | Successful `skill remove ` | Does **not** delete `$TINK_HOME/skills//` | -| X5 | `init` installs `manage-tink` | Embedded skill covers standalone lifecycle, operation-specific proof and partial-state reporting, project-contained lock sources, session-versus-persistent completion authority, any-update re-embed warning, library/catalog effects, skillsets, update, and destroy | +| X5 | `init` installs `manage-tink` | Embedded skill covers standalone lifecycle, operation-specific proof and partial-state reporting, project-contained lock sources, session-versus-persistent completion authority, any-update refresh warning, library/catalog effects, skillsets, update, and destroy | | X6 | `skill remove ` when that project's catalog metadata is malformed | Exit ≠ 0; project and library skill trees remain intact | | X7 | `skill remove ` when `$TINK_HOME/catalog` is a symlink | Exit ≠ 0; mentions the symlink; project skill and external catalog target remain byte-identical | @@ -288,7 +295,7 @@ Ids are stable. Tests must name or comment the id they prove. |---|---|---| | U1 | `update` when releases API is unreachable | Exit ≠ 0; clear download/metadata failure; binary unchanged | | U2 | `update` when latest release version matches this binary | Exit 0; stdout notes up to date; binary unchanged | -| U3 | `update` when a newer release asset exists for this host | Exit 0; replaces the running binary; stdout notes updated version | +| U3 | `update` when a newer release asset exists for this host | Exit 0; replaces the running binary; stdout notes updated version and the explicit per-project `tink skill refresh manage-tink` next step; does not mutate a project | | U4 | `update` receives a valid-digest archive whose payload fails the exact version probe | Exit ≠ 0; running binary remains byte-identical; no success output | | U5 | `update` metadata names an older semantic version | Exit ≠ 0; refuses downgrade before publication; running binary remains unchanged | | U6 | `install.sh` receives an invalid or non-executable verified payload while a binary exists | Exit ≠ 0; existing binary remains byte-identical; no success output | diff --git a/README.md b/README.md index 07d60c1..9ab0bc5 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ tink skill list tink skill check tink skill refresh tink skill refresh skill-name +tink skill refresh manage-tink tink skill remove skill-name ``` @@ -151,7 +152,11 @@ tink skill remove skill-name by the error or by `tink inspect`. - GitHub tree URLs are inspection inputs, not `skill add` sources. Remote adds follow the repository's default branch and record the selected skill path. -- Refresh only clean GitHub imports; local edits are refused. +- Refresh only clean GitHub imports; local edits are refused. The explicit + `tink skill refresh manage-tink` path instead owns the reserved embedded + package: it installs a missing copy, leaves a current copy unchanged, or + atomically replaces differing receipt-free contents. Remote provenance is + refused. - tink does not overwrite a project skill that differs from what it would install. - tink never inits Git, stages, commits, or pushes. @@ -227,7 +232,7 @@ header, including for an empty catalog. Within fields, backslash, tab, carriage return, and newline are escaped as `\\\\`, `\\t`, `\\r`, and `\\n` so every skill remains one three-column row. -**Breaking in 0.3.0:** `skill list --home` → `--catalog`; `skill list --stash` → `--library`. On-disk layout is still `$TINK_HOME/skills/` and `catalog/by-project/`. After a major CLI upgrade, refresh the live project skill: `tink skill remove manage-tink && tink init --no-zen --no-tink-skills`. +**Breaking in 0.3.0:** `skill list --home` → `--catalog`; `skill list --stash` → `--library`. On-disk layout is still `$TINK_HOME/skills/` and `catalog/by-project/`. After updating the binary, refresh each project's embedded skill with `tink skill refresh manage-tink`. Project lockfiles now use digest format/version 2 so file boundaries and Unix executable modes are actually pinned. An older lock is deliberately refused; diff --git a/skills/manage-tink/SKILL.md b/skills/manage-tink/SKILL.md index d5c700b..1cb0727 100644 --- a/skills/manage-tink/SKILL.md +++ b/skills/manage-tink/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-tink -description: "Tink CLI for repository-owned Agent Skills. Use when the user asks to initialize Tink; inspect GitHub skill sources; add, list, check, lock, verify, sync, refresh, or remove project skills; manage grouped skillsets; use the home library or catalog; harvest harness skills; configure shell completion; update Tink; re-embed manage-tink; or destroy project agent scaffolding." +description: "Tink CLI for repository-owned Agent Skills. Use when the user asks to initialize Tink; inspect GitHub skill sources; add, list, check, lock, verify, sync, refresh, or remove project skills; manage grouped skillsets; use the home library or catalog; harvest harness skills; configure shell completion; update Tink; refresh embedded manage-tink; or destroy project agent scaffolding." --- # Manage Tink @@ -76,7 +76,7 @@ harness roots, and only the matching `tink skillset add`, `refresh`, or known. **On failure:** Stop and report the failure. Do not repair Tink-managed state -by hand. Init, add, skillset refresh, and re-embedding can fail after an +by hand. Init, add, skillset refresh, and embedded-skill refresh can fail after an earlier project, library, catalog, or guidance write succeeded. Report each surface known or possibly changed; do not describe the failure as a no-op unless that was proved. @@ -118,20 +118,22 @@ mutation and requires authority for that exact file. **On failure:** Report the shell and command failure. Do not edit unrelated shell configuration. -### Step 6: Re-embed Manage Tink When Separately Authorized +### Step 6: Refresh Manage Tink When Separately Authorized After any binary update or observed contract mismatch, explain that the live skill may be stale. Do not replace it automatically. Before `destroy`, compare the active `tink destroy --help` boundary with this skill's ownership contract; if it is broader, stop and renew approval. If the user explicitly authorizes -re-embedding, run `tink skill remove manage-tink`, then -`tink init --no-zen --no-tink-skills`. +refreshing the embedded package, run `tink skill refresh manage-tink`. -**Expected:** Separate approval exists and the binary's embedded copy becomes -the live project skill. +**Expected:** Missing copies are installed, current copies report `Unchanged`, +and differing receipt-free copies are atomically replaced. The project, +library, and catalog are reconciled, and the binary's embedded copy becomes +the live project skill. A same-named skill with remote provenance is refused. -**On failure:** Stop after the failing command and report whether the old skill -was removed. Do not conceal a partially completed replacement. +**On failure:** Stop after the failing command and report which project, +library, and catalog states were proven. Do not conceal a partially completed +publication. ### Step 7: Prove the Post-state @@ -149,8 +151,8 @@ was removed. Do not conceal a partially completed replacement. - After `skillset remove`, verify project absence and library presence; its external definition should remain. - After update, resolve the active binary and probe its exact version. After - re-embedding, run project/catalog/library listings plus `tink skill check`; - structural check alone does not prove embedded payload identity. + refreshing embedded `manage-tink`, run project/catalog/library listings plus + `tink skill check`; check compares the live payload with the active binary. - After `destroy`, confirm `.agents/skills/` is gone, `.agents/` is gone only if it became empty, `ZEN.md` and `AGENTS.md` are preserved, unrelated `.agents/` siblings remain, and the project has no catalog rows; do not run `skill check`. @@ -202,12 +204,13 @@ from the mutation command alone. | Configure shell completion | Only the matching shell command | | Persist shell completion | Only the exact startup file the user authorizes | | Lock / verify / sync reproducible state | Only the matching `tink skill …` command; lock requires a project-contained source mapping for each local skill | -| Re-embed manage-tink | `tink skill remove manage-tink`, then `tink init --no-zen --no-tink-skills` | +| Refresh embedded manage-tink | `tink skill refresh manage-tink` | | Remove one project skill | `tink skill remove NAME` | -| Update the Tink binary | `tink update` only; re-embedding requires separate authority | +| Update the Tink binary | `tink update` only; refreshing embedded `manage-tink` requires separate authority | | Remove managed project skills / destroy Tink setup | `tink destroy` (TTY) or `tink destroy --yes` (scripts); guidance and unrelated `.agents/` siblings are preserved | -"Set up Tink" does **not** authorize ZEN, tink-skills, re-embedding, or destroy. +"Set up Tink" does **not** authorize ZEN, tink-skills, refreshing embedded +`manage-tink`, or destroy. ## Ownership (always) diff --git a/skills/manage-tink/references/commands.md b/skills/manage-tink/references/commands.md index 1c857cc..a6082b8 100644 --- a/skills/manage-tink/references/commands.md +++ b/skills/manage-tink/references/commands.md @@ -24,6 +24,7 @@ form for add, list, check, refresh, and remove. | Sync the exact pinned manifest set | `tink skill sync` (preflights expected project/library/catalog refusals, then publishes sequentially; rerun after an operational interruption) | | Refresh all clean imports | `tink skill refresh` | | Refresh one | `tink skill refresh NAME` | +| Refresh the active binary's embedded manage-tink | `tink skill refresh manage-tink` (explicitly replaces a differing receipt-free reserved copy; refuses remote provenance) | | Remove one project skill | `tink skill remove NAME` | | Add a pinned skillset | `tink skillset add NAME-skillset` | | List project skillsets | `tink skillset list` | @@ -31,7 +32,6 @@ form for add, list, check, refresh, and remove. | Refresh a clean pinned skillset | `tink skillset refresh NAME-skillset` | | Remove one project skillset | `tink skillset remove NAME-skillset` | | Update the tink CLI binary | `tink update` (newer host asset only; verifies release digest, archive shape, and exact candidate version before replacement) | -| Re-embed manage-tink after separate approval | `tink skill remove manage-tink`, then `tink init --no-zen --no-tink-skills` | | Destroy managed project skills | `tink destroy --yes` (non-TTY/scripts) or `tink destroy` (TTY, confirm `y`); preserves guidance and unrelated `.agents/` siblings | ## Layout facts diff --git a/src/check.rs b/src/check.rs index 47dec6a..c505186 100644 --- a/src/check.rs +++ b/src/check.rs @@ -28,7 +28,10 @@ fn read_skill_entry(path: &Path) -> Result, Error> { } let skill = skills::read_skill(path, true)?; skills::validate_skill_tree(path)?; - provenance::read(&skill)?; + let provenance = provenance::read(&skill)?; + if skill.name == "manage-tink" && provenance.is_none() { + crate::manage_tink::require_current(&skill)?; + } Ok(Some(skill)) } diff --git a/src/lib.rs b/src/lib.rs index a4a1461..cd41c59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,7 +153,7 @@ pub enum SkillCommand { }, /// Install missing or pinned skills from the project manifest and lockfile Sync, - /// Refresh clean GitHub-imported skills; refuse local modifications + /// Refresh clean GitHub imports or the reserved embedded manage-tink Refresh { /// Optional skill name; default refreshes all imported skills name: Option, @@ -722,6 +722,16 @@ fn dispatch_skill_harvest(cwd: &Path) -> Result<(), Error> { fn dispatch_skill_refresh(cwd: &Path, name: Option<&str>) -> Result<(), Error> { let style = CliStyle::auto_stdout(); match name { + Some("manage-tink") => { + let outcome = manage_tink::refresh_manage_tink(cwd)?; + let action = match outcome { + manage_tink::RefreshOutcome::Installed => style.success("Installed"), + manage_tink::RefreshOutcome::Unchanged => style.muted("Unchanged"), + manage_tink::RefreshOutcome::Refreshed => style.success("Refreshed"), + }; + println!("{} {}", action, style.skill("manage-tink")); + Ok(()) + } Some(name) => { let changed = refresh::refresh_skill(cwd, name)?; if changed { diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 480a402..c7e8454 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -3,14 +3,25 @@ use std::path::Path; use crate::add; +use crate::catalog; +use crate::check; use crate::error::Error; -use crate::paths::map_io; +use crate::library; +use crate::paths::{map_io, refuse_symlink}; +use crate::provenance; use crate::skills::{self, Skill}; const SKILL_MD: &str = include_str!("../skills/manage-tink/SKILL.md"); const OPENAI_YAML: &str = include_str!("../skills/manage-tink/agents/openai.yaml"); const COMMANDS_MD: &str = include_str!("../skills/manage-tink/references/commands.md"); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RefreshOutcome { + Installed, + Unchanged, + Refreshed, +} + /// Materialize the embedded tree for read-only validation or later publication. /// The returned guard owns the bytes referenced by `Skill`. pub(crate) fn prepare_manage_tink() -> Result<(tempfile::TempDir, Skill), Error> { @@ -33,6 +44,84 @@ pub(crate) fn prepare_manage_tink() -> Result<(tempfile::TempDir, Skill), Error> Ok((staging, skill)) } +pub(crate) fn is_current(installed: &Skill) -> Result { + let (_staging, embedded) = prepare_manage_tink()?; + skills::skill_contents_equal(&installed.path, &embedded.path) +} + +/// Require an installed embedded copy to match the payload in this binary. +pub(crate) fn require_current(installed: &Skill) -> Result<(), Error> { + if is_current(installed)? { + return Ok(()); + } + Err(Error::msg( + "manage-tink differs from this Tink binary; run `tink skill refresh manage-tink`", + )) +} + +fn refuse_remote_library_collision() -> Result<(), Error> { + let Some(home) = crate::home::existing_inventory_root(None)? else { + return Ok(()); + }; + let target = crate::home::skills_library_path(&home).join("manage-tink"); + refuse_symlink(&target)?; + if !target.is_dir() { + return Ok(()); + } + let library_skill = skills::read_skill(&target, true)?; + if provenance::read(&library_skill)?.is_some() { + return Err(Error::msg( + "Refusing to replace library manage-tink with remote provenance", + )); + } + Ok(()) +} + +pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result { + check::check_zen_coupling(project_root)?; + let agents = crate::home::project_agents_path(project_root); + let skills_root = crate::home::project_skills_path(project_root); + let target = skills_root.join("manage-tink"); + refuse_symlink(&agents)?; + refuse_symlink(&skills_root)?; + refuse_symlink(&target)?; + refuse_remote_library_collision()?; + + if !target.exists() { + install_manage_tink(project_root)?; + return Ok(RefreshOutcome::Installed); + } + if !target.is_dir() { + return Err(Error::msg("Installed manage-tink is not a directory")); + } + + let installed = skills::read_skill(&target, true)?; + skills::validate_skill_tree(&target)?; + if provenance::read(&installed)?.is_some() { + return Err(Error::msg( + "Refusing to replace manage-tink with remote provenance", + )); + } + let (_staging, embedded) = prepare_manage_tink()?; + if !skills::skill_contents_equal(&installed.path, &embedded.path)? { + library::preflight_deposit(&embedded, None)?; + catalog::preflight_deposit_skill(project_root)?; + skills::replace_embedded_verified(&embedded, &skills_root)?; + library::deposit(&embedded, None)?; + catalog::deposit_skill(project_root, "manage-tink")?; + + let refreshed = skills::read_skill(&target, true)?; + require_current(&refreshed)?; + return Ok(RefreshOutcome::Refreshed); + } + + library::preflight_deposit(&installed, None)?; + catalog::preflight_deposit_skill(project_root)?; + library::sync_from_installed(&installed)?; + catalog::deposit_skill(project_root, "manage-tink")?; + Ok(RefreshOutcome::Unchanged) +} + /// Stage the embedded skill and install it into the project via `add`. /// /// Uses the quiet add path so init can own the closing narrative. diff --git a/src/skills.rs b/src/skills.rs index 9a61931..da0bcae 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -717,11 +717,10 @@ pub(crate) fn publish_staged_tree( Ok(target.to_path_buf()) } -/// Replace an existing imported skill after dirty-tree preflight elsewhere. -pub fn replace_verified( +fn replace_verified_inner( skill: &Skill, destination_root: &Path, - provenance: &Provenance, + provenance: Option<&Provenance>, ) -> Result { require_safe_tree(&skill.path)?; let target = destination_root.join(&skill.name); @@ -744,10 +743,29 @@ pub fn replace_verified( output::display_path(&skill.path) ))); } - provenance::write_file(&staged.join(provenance::SIDECAR_FILE), provenance)?; + if let Some(provenance) = provenance { + provenance::write_file(&staged.join(provenance::SIDECAR_FILE), provenance)?; + } publish_staged_tree(staging, staged, &target) } +/// Replace an existing imported skill after dirty-tree preflight elsewhere. +pub fn replace_verified( + skill: &Skill, + destination_root: &Path, + provenance: &Provenance, +) -> Result { + replace_verified_inner(skill, destination_root, Some(provenance)) +} + +/// Replace the receipt-free embedded skill after its ownership preflight. +pub(crate) fn replace_embedded_verified( + skill: &Skill, + destination_root: &Path, +) -> Result { + replace_verified_inner(skill, destination_root, None) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/update.rs b/src/update.rs index 46bbefd..dc1a7aa 100644 --- a/src/update.rs +++ b/src/update.rs @@ -702,6 +702,10 @@ pub fn print_report(report: &UpdateReport) -> Result<(), Error> { "{}", style.muted(format!("Installed to {}", output::display_path(path))) ))?; + output::stdout_line(format_args!( + "{}", + style.muted("Next: run `tink skill refresh manage-tink` in each Tink project") + ))?; } } Ok(()) diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 0bcf421..1b4f35b 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -2103,6 +2103,38 @@ fn c7_check_rejects_unclosed_skill_frontmatter() { )); } +#[test] +fn c8_check_rejects_stale_embedded_manage_tink() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + fs::write( + Workspace::skill_path(&project, "manage-tink").join("references/commands.md"), + "stale embedded contents\n", + ) + .expect("replace embedded commands reference"); + + ws.cmd(&project) + .args(["skill", "check"]) + .assert() + .failure() + .stderr( + predicate::str::contains("manage-tink differs from this Tink binary") + .and(predicate::str::contains("tink skill refresh manage-tink")), + ); + + ws.cmd(&project) + .args(["skill", "lock"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "manage-tink differs from this Tink binary", + )); + assert!(!project.join(".tink/skills.toml").exists()); + assert!(!project.join(".tink/skills.lock").exists()); +} + // --- M*: project manifest --- #[test] @@ -3717,6 +3749,214 @@ fn p8_refresh_all_preflights_before_updating_any_skill() { ); } +#[test] +fn p9_refresh_manage_tink_installs_missing_embedded_copy() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Installed manage-tink")); + + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + +#[test] +fn p10_refresh_manage_tink_reports_current_copy_unchanged() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let before = fs::read(installed.join("SKILL.md")).expect("installed SKILL.md"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Unchanged manage-tink")); + + assert_eq!( + fs::read(installed.join("SKILL.md")).expect("refreshed SKILL.md"), + before + ); + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + +#[test] +fn p11_refresh_manage_tink_replaces_differing_reserved_copy() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let commands = installed.join("references/commands.md"); + let embedded = fs::read(&commands).expect("embedded commands reference"); + fs::write(&commands, "local edit to reserved embedded contents\n") + .expect("modify embedded commands reference"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Refreshed manage-tink")); + + assert_eq!(fs::read(&commands).expect("refreshed commands"), embedded); + assert_eq!( + fs::read( + ws.library_skill("manage-tink") + .join("references/commands.md") + ) + .expect("library commands"), + embedded + ); + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + +#[test] +fn p12_refresh_manage_tink_refuses_remote_provenance_collision() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + let remote = ws.root.join("remote-manage-tink"); + init_repo(&remote); + write_skill(&remote, "manage-tink", "remote user-owned contents"); + commit_all(&remote, "remote manage-tink"); + let public = "https://github.com/example/remote-manage-tink.git"; + let mut add = ws.cmd(&project); + add.args(["skill", "add", "example/remote-manage-tink"]); + add.envs(github_redirect(&remote, public)); + add.assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let before = fs::read(installed.join("SKILL.md")).expect("remote SKILL.md"); + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .failure() + .stderr(predicate::str::contains("remote provenance")); + assert_eq!( + fs::read(installed.join("SKILL.md")).expect("preserved remote SKILL.md"), + before + ); +} + +#[test] +fn p13_refresh_manage_tink_refuses_remote_library_collision_when_project_is_missing() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + let remote = ws.root.join("remote-library-manage-tink"); + init_repo(&remote); + write_skill(&remote, "manage-tink", "remote library contents"); + commit_all(&remote, "remote library manage-tink"); + let public = "https://github.com/example/remote-library-manage-tink.git"; + let mut add = ws.cmd(&project); + add.args(["skill", "add", "example/remote-library-manage-tink"]); + add.envs(github_redirect(&remote, public)); + add.assert().success(); + ws.cmd(&project) + .args(["skill", "remove", "manage-tink"]) + .assert() + .success(); + + let library = ws.library_skill("manage-tink"); + let skill_before = fs::read(library.join("SKILL.md")).expect("library SKILL.md"); + let receipt_before = fs::read(library.join(".tink-source.json")).expect("library provenance"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .failure() + .stderr(predicate::str::contains("remote provenance")); + + assert!(!Workspace::skill_path(&project, "manage-tink").exists()); + assert_eq!( + fs::read(library.join("SKILL.md")).expect("preserved library SKILL.md"), + skill_before + ); + assert_eq!( + fs::read(library.join(".tink-source.json")).expect("preserved library provenance"), + receipt_before + ); +} + +#[test] +fn p14_refresh_manage_tink_preserves_remote_library_collision_for_existing_projects() { + let ws = Workspace::new(); + let current_project = ws.project("current"); + let stale_project = ws.project("stale"); + for project in [¤t_project, &stale_project] { + ws.cmd(project).arg("init").assert().success(); + } + fs::write( + Workspace::skill_path(&stale_project, "manage-tink").join("references/commands.md"), + "stale project contents\n", + ) + .expect("make stale embedded project copy"); + + let remote_project = ws.project("remote"); + ws.cmd(&remote_project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + let remote = ws.root.join("remote-existing-manage-tink"); + init_repo(&remote); + write_skill(&remote, "manage-tink", "remote library contents"); + commit_all(&remote, "remote existing manage-tink"); + let public = "https://github.com/example/remote-existing-manage-tink.git"; + let mut add = ws.cmd(&remote_project); + add.args(["skill", "add", "example/remote-existing-manage-tink"]); + add.envs(github_redirect(&remote, public)); + add.assert().success(); + + let library = ws.library_skill("manage-tink"); + let library_skill_before = fs::read(library.join("SKILL.md")).expect("library SKILL.md"); + let library_receipt_before = + fs::read(library.join(".tink-source.json")).expect("library provenance"); + + for project in [¤t_project, &stale_project] { + let installed = Workspace::skill_path(project, "manage-tink"); + let project_before = + fs::read(installed.join("references/commands.md")).expect("project commands"); + ws.cmd(project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .failure() + .stderr(predicate::str::contains("remote provenance")); + assert_eq!( + fs::read(installed.join("references/commands.md")).expect("preserved project commands"), + project_before + ); + } + + assert_eq!( + fs::read(library.join("SKILL.md")).expect("preserved library SKILL.md"), + library_skill_before + ); + assert_eq!( + fs::read(library.join(".tink-source.json")).expect("preserved library provenance"), + library_receipt_before + ); +} + // --- D*: destroy --- #[test] @@ -4014,6 +4254,7 @@ fn x5_manage_tink_documents_remove_and_catalog_sync() { "tink skill lock", "tink skill verify", "tink skill sync", + "tink skill refresh manage-tink", "tink skillset add NAME-skillset", "tink skillset list", "tink skillset list --library", @@ -4078,12 +4319,14 @@ fn x5_manage_tink_documents_remove_and_catalog_sync() { "manage-tink procedures must state expected and failure behavior: {skill_md}" ); assert!( - !skill_md.contains("tink skill remove manage-tink && tink init"), - "manage-tink must not chain re-embedding onto binary updates: {skill_md}" + !skill_md.contains("tink skill remove manage-tink") + && !commands.contains("tink skill remove manage-tink"), + "manage-tink must not teach the remove-then-init ceremony: {skill_md}\n{commands}" ); assert!( skill_md.contains("After any binary update or observed contract mismatch") - && skill_md.contains("tink destroy --help"), + && skill_md.contains("tink destroy --help") + && skill_md.contains("check compares the live payload with the active binary"), "manage-tink must treat every update as possible contract drift: {skill_md}" ); assert!( @@ -5249,7 +5492,11 @@ fn u3_update_replaces_binary_when_newer_release_exists() { .env("TINK_HOME", &ws.inventory) .assert() .success() - .stdout(predicate::str::contains("Updated").and(predicate::str::contains("v99.0.0"))); + .stdout( + predicate::str::contains("Updated") + .and(predicate::str::contains("v99.0.0")) + .and(predicate::str::contains("tink skill refresh manage-tink")), + ); assert!(installed.is_file()); assert_eq!(