Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/bump-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,26 @@ jobs:
exit 1
fi

locked="$(awk '
/^\[\[package\]\]$/ { package = ""; next }
/^name = "tink"$/ { package = "tink"; next }
package == "tink" && /^version = "/ {
version = $0
sub(/^version = "/, "", version)
sub(/"$/, "", version)
print version
exit
}
' Cargo.lock)"
if [ -z "${locked}" ]; then
echo "Could not read tink version from Cargo.lock" >&2
exit 1
fi
if [ "${locked}" != "${current}" ]; then
echo "Cargo.lock tink version ${locked} does not match Cargo.toml ${current}" >&2
exit 1
fi

current_tag="v${current}"
if git rev-parse --verify --quiet "refs/tags/${current_tag}" >/dev/null; then
current_tagged_version="$(git show "${current_tag}:Cargo.toml" | sed -n 's/^version = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"/\1/p' | head -n1)"
Expand All @@ -132,6 +152,59 @@ jobs:
fi
fi

# An intentionally pre-bumped, untagged manifest (for example 1.0.0)
# publishes that exact version instead of incrementing it again.
if ! git rev-parse --verify --quiet "refs/tags/${current_tag}" >/dev/null; then
baseline_tag="$(gh release view --json tagName --jq '.tagName' 2>/dev/null || true)"
if [ -z "${baseline_tag}" ]; then
echo "Cannot publish ${current_tag} without a published release baseline" >&2
exit 1
fi
baseline_version="${baseline_tag#v}"
if ! python3 - "${current}" "${baseline_version}" <<'PY'
import re
import sys

current, baseline = sys.argv[1:]
pattern = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)")
if pattern.fullmatch(current) is None or pattern.fullmatch(baseline) is None:
raise SystemExit(1)
raise SystemExit(0 if tuple(map(int, current.split("."))) > tuple(map(int, baseline.split("."))) else 1)
PY
then
echo "Pre-bumped version ${current} must be newer than published ${baseline_tag}" >&2
exit 1
fi
if ! git rev-parse --verify --quiet "refs/tags/${baseline_tag}" >/dev/null; then
echo "Published baseline tag ${baseline_tag} is missing locally" >&2
exit 1
fi
baseline_tagged_version="$(git show "${baseline_tag}:Cargo.toml" | sed -n 's/^version = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"/\1/p' | head -n1)"
if [ "${baseline_tagged_version}" != "${baseline_version}" ]; then
echo "Baseline tag ${baseline_tag} does not contain Cargo version ${baseline_version}" >&2
exit 1
fi
if ! git merge-base --is-ancestor "${baseline_tag}^{commit}" origin/main; then
echo "Baseline tag ${baseline_tag} is not contained in origin/main" >&2
exit 1
fi
baseline_release_draft="$(gh release view "${baseline_tag}" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
if [ "${baseline_release_draft}" != "false" ]; then
echo "Baseline release ${baseline_tag} is not published" >&2
exit 1
fi
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "origin/main advanced after this workflow started; a newer run owns the release" >&2
exit 1
fi

git tag "${current_tag}"
git push --atomic origin HEAD:main "refs/tags/${current_tag}:refs/tags/${current_tag}"
gh workflow run release.yml --ref "${current_tag}"
echo "Dispatched intentional release for ${current_tag}"
exit 0
fi

IFS=. read -r major minor patch <<< "${current}"
new="${major}.${minor}.$((patch + 1))"
tag="v${new}"
Expand Down
6 changes: 3 additions & 3 deletions ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Ids are stable. Tests must name or comment the id they prove.
| C1 | `skill check` after valid `init` + `skill add` | Exit 0 |
| C2 | `skill check` without `.agents/skills` | Exit ≠ 0 |
| C3 | `skill check` when `.agents` is a symlink | Exit ≠ 0; refuse |
| C4 | `skill check` | Performs no network I/O and no filesystem writes. Sensor: manual. |
| C4 | `skill check` | Leaves the project and Tink home trees byte-for-byte and mode-for-mode unchanged; succeeds without external commands on `PATH` |
| 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 |
Expand Down Expand Up @@ -318,8 +318,8 @@ Ids are stable. Tests must name or comment the id they prove.

| Id | Action | Expect |
|---|---|---|
| S1 | Any successful command | Does not `git init`, stage, commit, or push. Sensor: S1 (partial: `init` only). |
| S2 | Home root | Never treated as an agent discovery root. Sensor: manual. |
| S1 | Successful local and remote commands | Tink's centralized Git process boundary refuses root `init`, `add`, `commit`, and `push` commands; representative init and remote-add paths do not create project Git state |
| S2 | A valid skill exists only in the home library | Project `skill list` and `skill check` do not treat it as live; explicit `skill add <name>` promotes it into the project, after which both commands observe it |
| S3 | `skill remove` or `destroy --yes` when neither `TINK_HOME` nor `HOME` can resolve an inventory | Exit 0; completes local cleanup without creating or mutating inventory state |

## Proof
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tink"
version = "0.3.20"
version = "1.0.0"
edition = "2024"
rust-version = "1.95"
description = "Install Agent Skills into a project's .agents/skills/"
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

Skill manager that makes sense to you, and your agent.

Tink 1.0 is feature-complete for the v1 acceptance boundary in
[`ACCEPTANCE.md`](ACCEPTANCE.md). Maintenance prioritizes correctness,
security, compatibility, and a simpler everyday experience over new lifecycle
machinery.

Live skills live only under a project’s `.agents/skills/<name>/`. Grouped
skillsets use one canonical nested root at
`.agents/skills/<name>-skillset/<member>/`. There
Expand Down Expand Up @@ -118,8 +123,10 @@ flowchart LR
tink -->|"add <library-name>"| live
```

Home (`~/.tink` or `$TINK_HOME`) is **not** an agent discovery root. Library holds
skill trees; catalog holds by-project **names** only.
Tink lists and validates live project skills only under `.agents/skills/`. It
never promotes a home-library entry automatically or configures an agent
harness to discover the home. Library holds skill trees; catalog holds
by-project **names** only.

## Use (everyday)

Expand Down
45 changes: 45 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,36 @@ fn git_command_args<'a>(args: &[&'a str]) -> Vec<&'a str> {
full
}

fn git_subcommand<'a>(args: &[&'a str]) -> Option<&'a str> {
let mut index = 0;
while let Some(argument) = args.get(index) {
match *argument {
"-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" => index += 2,
value if value.starts_with('-') => index += 1,
value => return Some(value),
}
}
None
}

fn guard_git_command(args: &[&str]) -> Result<(), Error> {
if matches!(
git_subcommand(args),
Some("init" | "add" | "commit" | "push")
) {
return Err(Error::msg("Tink refuses project-mutating Git commands"));
}
Ok(())
}

fn run_git(
args: &[&str],
cwd: Option<&Path>,
non_interactive: bool,
io_context: &str,
missing_message: Option<&str>,
) -> Result<std::process::Output, Error> {
guard_git_command(args)?;
let mut command = Command::new("git");
command.args(git_command_args(args));
if non_interactive {
Expand Down Expand Up @@ -236,4 +259,26 @@ mod tests {
]
);
}

#[test]
fn git_guard_rejects_project_mutations_and_allows_owned_reads() {
for args in [
&["init"][..],
&["add", "."][..],
&["commit", "-m", "message"][..],
&["push", "origin", "main"][..],
&["-C", "/tmp/repo", "commit"][..],
] {
assert!(guard_git_command(args).is_err(), "must refuse {args:?}");
}

for args in [
&["ls-remote", "https://example.test", "HEAD"][..],
&["clone", "https://example.test/repo.git", "/tmp/repo"][..],
&["rev-parse", "HEAD"][..],
&["-C", "/tmp/repo", "worktree", "add", "/tmp/tree"][..],
] {
assert!(guard_git_command(args).is_ok(), "must allow {args:?}");
}
}
}
121 changes: 120 additions & 1 deletion tests/acceptance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::fs;
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use tempfile::TempDir;
Expand Down Expand Up @@ -198,6 +199,59 @@ fn github_redirect(local_repo: &Path, public_url: &str) -> Vec<(String, String)>
]
}

#[derive(Debug, Eq, PartialEq)]
struct TreeEntry {
path: PathBuf,
kind: &'static str,
mode: u32,
contents: Vec<u8>,
}

fn snapshot_tree(root: &Path) -> Vec<TreeEntry> {
fn visit(root: &Path, path: &Path, entries: &mut Vec<TreeEntry>) {
let mut children: Vec<_> = fs::read_dir(path)
.expect("read snapshot directory")
.map(|entry| entry.expect("snapshot entry").path())
.collect();
children.sort();
for child in children {
let metadata = fs::symlink_metadata(&child).expect("snapshot metadata");
let file_type = metadata.file_type();
let kind = if file_type.is_dir() {
"directory"
} else if file_type.is_file() {
"file"
} else if file_type.is_symlink() {
"symlink"
} else if file_type.is_socket() {
"socket"
} else {
"special"
};
entries.push(TreeEntry {
path: child
.strip_prefix(root)
.expect("snapshot path under root")
.to_path_buf(),
kind,
mode: metadata.permissions().mode(),
contents: if file_type.is_file() {
fs::read(&child).expect("snapshot file")
} else {
Vec::new()
},
});
if file_type.is_dir() {
visit(root, &child, entries);
}
}
}

let mut entries = Vec::new();
visit(root, root, &mut entries);
entries
}

// --- I*: init ---

#[test]
Expand Down Expand Up @@ -2030,6 +2084,24 @@ fn c3_check_refuses_agents_symlink() {
.stderr(predicate::str::contains("symlink").or(predicate::str::contains("Symlink")));
}

#[test]
fn c4_check_preserves_project_and_home_trees_without_external_commands() {
let ws = Workspace::new();
let project = ws.project("app");
ws.cmd(&project).arg("init").assert().success();
let project_before = snapshot_tree(&project);
let home_before = snapshot_tree(&ws.inventory);

ws.cmd(&project)
.env("PATH", "")
.args(["skill", "check"])
.assert()
.success();

assert_eq!(snapshot_tree(&project), project_before);
assert_eq!(snapshot_tree(&ws.inventory), home_before);
}

#[test]
fn c5_check_fails_with_corrupt_installed_skill() {
let ws = Workspace::new();
Expand Down Expand Up @@ -4430,12 +4502,59 @@ fn x7_remove_refuses_symlinked_catalog_without_external_or_project_writes() {
// --- S*: safety ---

#[test]
fn s1_init_does_not_create_git_repo() {
fn s1_successful_local_and_remote_commands_do_not_mutate_project_git() {
let ws = Workspace::new();
let project = ws.project("app");
assert!(!project.join(".git").exists());
ws.cmd(&project).arg("init").assert().success();
assert!(!project.join(".git").exists());

let remote = ws.root.join("remote");
init_repo(&remote);
write_skill(&remote, "remote-skill", "remote");
commit_all(&remote, "initial");
let public_url = "https://github.com/example/remote-skill.git";
let envs = github_redirect(&remote, public_url);
ws.cmd(&project)
.envs(envs)
.args(["skill", "add", "example/remote-skill"])
.assert()
.success();
assert!(!project.join(".git").exists());
}

#[test]
fn s2_library_skill_is_not_project_live_until_explicitly_added() {
let ws = Workspace::new();
let bootstrap = ws.project("bootstrap");
ws.cmd(&bootstrap)
.args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"])
.assert()
.success();
write_skill(&ws.library_skill("library-only"), "library-only", "body");

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", "list"])
.assert()
.success()
.stdout(predicate::str::contains("library-only").not());
ws.cmd(&project).args(["skill", "check"]).assert().success();

ws.cmd(&project)
.args(["skill", "add", "library-only"])
.assert()
.success();
ws.cmd(&project)
.args(["skill", "list"])
.assert()
.success()
.stdout(predicate::str::contains("library-only"));
ws.cmd(&project).args(["skill", "check"]).assert().success();
}

#[test]
Expand Down
Loading