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
13 changes: 11 additions & 2 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,21 @@
replaces skill content is checked by digest first; `skill list` reports `managed-blocked` and
`managed-not-allowed`, and `--check` fails on them. An `https://` source can name a credential
command for sign-in, receive a fixed, inspectable report after each apply, honor `editable` and
point refusals at a `request_url`. Session-start hooks in Claude Code and Gemini CLI and a
per-user timer (at login, then hourly) keep machines up to date, and `managed hooks --system`
point refusals at a `request_url`. Session-start hooks in Claude Code, Codex CLI, Cursor and Pi
and a per-user timer (at login, then hourly) keep machines up to date, and `managed hooks --system`
prints what an administrator installs machine-wide. Nothing changes until a source is
configured. See [ADR-0016](docs/adr/0016-machines-follow-a-signed-managed-state.md), the
[managed state](webdocs/skill-management/managed-state.mdx) format and
[Hardening managed machines](webdocs/security/managed-machines.mdx).
- **Managed session hooks cover Claude Code, Codex CLI, Cursor and Pi.** Every other agent,
Gemini CLI included, is kept up to date by the timer alone, and the next apply removes a
FastSkill hook left in one of them. Enroll and apply print the approval step for a hook that is
new or changed (Claude Code's `/hooks` review, Codex's `/hooks` trust); a hook file FastSkill
can't change, such as a Codex `config.toml` that already has a `hooks` table or a Pi extension
file FastSkill didn't write, is a warning, not an error. Each agent gets the sign-in notice in
its own form. `managed hooks --system` prints a TOML entry for Codex's `requirements.toml`, and
says Pi has no administrator-level location. See the third amendment of
[ADR-0016](docs/adr/0016-machines-follow-a-signed-managed-state.md).
- **`storage::git_isolated::clone_isolated` for cloning repositories you do not control.** The
library clone reads no system, global or user git configuration and inherits no environment
beyond `PATH`, proxy and CA settings, so a host's credential helper, `insteadOf` rewrite or
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

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

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ repository = "https://github.com/gofastskill/fastskill"
# and compiles two mutually-incompatible copies into the binary.
#
# aikit-sdk for agent metadata file resolution
aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" }
aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" }
# aikit-evals provides the generic eval runner infrastructure
aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" }
aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" }
# aikit-skillopt provides the text-gradient skill optimization loop
aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" }
aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" }

# Async runtime
tokio = { version = "1.52", features = ["rt-multi-thread", "net", "fs", "io-util", "macros", "process"] }
Expand Down Expand Up @@ -66,7 +66,7 @@ tower = { version = "0.5", features = ["util", "timeout", "limit"] }
tower-http = { version = "0.7", features = ["cors", "trace", "compression-gzip", "fs"] }

# CLI framework for versioned API server hosting
cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "86c22b28cd4a36474d194b44b0bc6fc4a780c553", default-features = false, features = ["api-server"] }
cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "388e97b0ecbf244593b033fd5b5bd4ca882bebbb", default-features = false, features = ["api-server"] }

# MCP protocol types and transports. Version and feature set MUST track
# cli-framework's own `rmcp` dependency: `fastskill mcp serve` wraps
Expand Down
2 changes: 1 addition & 1 deletion crates/fastskill-cli/src/commands/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ impl Environment {
home,
project_skills: crate::config::resolve_skills_storage_directory(false).ok(),
interactive: std::io::stdout().is_terminal(),
hooks: Some(HookSetup::new(managed::hooks::quote_program(&exe))),
hooks: Some(HookSetup::for_exe(&exe)),
timer,
exe,
background: super::managed_hooks::start_background_apply,
Expand Down
37 changes: 23 additions & 14 deletions crates/fastskill-cli/src/commands/managed_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use super::managed::{Environment, ManagedArgs};
use crate::error::CliResult;
use fastskill_core::core::managed::hooks::{self, agent_of, hook_id, quote_program, HookSetup};
use fastskill_core::core::managed::hooks::{self, agent_of, hook_id, HookSetup};
use fastskill_core::core::managed::timer::{self, TimerOs};
use fastskill_core::core::managed::{self, ManagedStatus};
use serde_json::{json, Value};
Expand Down Expand Up @@ -84,7 +84,7 @@ pub fn run_hooks(env: &Environment, args: &ManagedArgs) -> CliResult<String> {
let setup = env
.hooks
.clone()
.unwrap_or_else(|| HookSetup::new(quote_program(&env.exe)));
.unwrap_or_else(|| HookSetup::for_exe(&env.exe));
let value = if args.system {
system_json(&setup, TimerOs::current(), &env.exe)
} else {
Expand All @@ -101,7 +101,7 @@ pub fn run_hooks(env: &Environment, args: &ManagedArgs) -> CliResult<String> {
}

fn user_json(env: &Environment, setup: &HookSetup) -> Value {
let agents: Vec<Value> = aikit_sdk::session_hook_agents()
let agents: Vec<Value> = hooks::hooked_agents()
.into_iter()
.map(|key| {
let id = hook_id(key);
Expand Down Expand Up @@ -160,17 +160,26 @@ fn system_json(setup: &HookSetup, os: Option<TimerOs>, exe: &Path) -> Value {
fn render_system(value: &Value) -> String {
let mut lines = Vec::new();
for hook in value["hooks"].as_array().into_iter().flatten() {
let file = hook["file"].as_str().unwrap_or("(no administrator file)");
lines.push(format!(
"{}: merge into {file}{}",
hook["agent"].as_str().unwrap_or_default(),
if hook["present"] == true {
" (already present)"
} else {
""
}
));
lines.push(serde_json::to_string_pretty(&hook["entry"]).unwrap_or_default());
let agent = hook["agent"].as_str().unwrap_or_default();
let (Some(file), Some(text)) = (hook["file"].as_str(), hook["text"].as_str()) else {
lines.push(format!(
"{agent}: no administrator-level location; the machine-wide timer covers it"
));
lines.push(String::new());
continue;
};
let verb = if hook["format"] == "toml" {
"add to"
} else {
"merge into"
};
let present = if hook["present"] == true {
" (already present)"
} else {
""
};
lines.push(format!("{agent}: {verb} {file}{present}"));
lines.push(text.trim_end().to_string());
lines.push(String::new());
}
let plan = &value["timer"];
Expand Down
68 changes: 61 additions & 7 deletions crates/fastskill-cli/src/commands/managed_hooks_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ fn a_hook_starts_an_apply_and_prints_what_the_user_must_act_on() {
.contains("no managed skills have been applied"));
}

#[test]
fn each_agent_gets_the_notice_in_its_own_form() {
let dir = TempDir::new().unwrap();
let env = environment(&dir, true, false);
enroll_record(&env);
let notice =
notice(&managed::status(&env.layout, &env.settings, chrono::Utc::now()).unwrap()).unwrap();
let printed = |agent: &str| run_hook(&env, &hook_id(agent)).unwrap();
let field = |agent: &str, key: &str| {
let value: Value = serde_json::from_str(&printed(agent)).unwrap();
value[key].as_str().unwrap().to_string()
};
assert_eq!(field("claude", "systemMessage"), notice);
assert_eq!(field("codex", "systemMessage"), notice);
// Cursor passes the text to the model; Pi shows plain text in its interface.
assert_eq!(field("cursor", "additional_context"), notice);
assert_eq!(printed("pi"), notice);
}

#[test]
fn a_hook_does_nothing_when_there_is_nothing_to_do() {
let dir = TempDir::new().unwrap();
Expand Down Expand Up @@ -165,14 +184,20 @@ fn hooks_show_each_agent_and_the_timer() {
let dir = TempDir::new().unwrap();
let mut env = environment(&dir, true, false);
let setup = hook_setup(no_admin);
aikit_sdk::register_session_hook(&env.home, "claude", &setup.hook("claude")).unwrap();
std::fs::create_dir_all(env.home.join(".gemini")).unwrap();
std::fs::write(env.home.join(".gemini/settings.json"), "not json").unwrap();
for agent in ["claude", "pi"] {
aikit_sdk::register_session_hook(&env.home, agent, &setup.hook(agent)).unwrap();
}
std::fs::create_dir_all(env.home.join(".cursor")).unwrap();
std::fs::write(env.home.join(".cursor/hooks.json"), "not json").unwrap();
env.hooks = Some(setup);

let text = run_hooks(&env, &args(false, false)).unwrap();
assert!(text.contains("claude user-level"), "{text}");
assert!(text.contains("gemini unknown: "), "{text}");
assert!(text.contains("codex none"), "{text}");
assert!(text.contains("cursor unknown: "), "{text}");
assert!(text.contains("pi user-level"), "{text}");
// Gemini CLI is reached by the timer alone.
assert!(!text.contains("gemini"), "{text}");
assert!(text.contains("Timer: none on this platform"), "{text}");

let mut timer = TimerSetup {
Expand All @@ -192,7 +217,15 @@ fn hooks_show_each_agent_and_the_timer() {

timer::install(&timer).unwrap();
let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, false)).unwrap()).unwrap();
let agents: Vec<&str> = value["agents"]
.as_array()
.unwrap()
.iter()
.map(|agent| agent["agent"].as_str().unwrap())
.collect();
assert_eq!(agents, vec!["claude", "codex", "cursor", "pi"]);
assert_eq!(value["agents"][0]["user"], true);
assert!(value["agents"][2]["problem"].is_string());
assert_eq!(value["timer"]["installed"], true);
let text = run_hooks(&env, &args(false, false)).unwrap();
assert!(
Expand Down Expand Up @@ -222,18 +255,39 @@ fn system_hooks_print_what_an_administrator_installs() {
let dir = TempDir::new().unwrap();
let env = environment(&dir, true, false);
let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, true)).unwrap()).unwrap();
assert_eq!(value["hooks"][0]["agent"], "claude");
let hooks = value["hooks"].as_array().unwrap();
let agents: Vec<&str> = hooks
.iter()
.map(|hook| hook["agent"].as_str().unwrap())
.collect();
assert_eq!(agents, vec!["claude", "codex", "cursor", "pi"]);
assert_eq!(value["timer"].is_null(), TimerOs::current().is_none());
// Without a setup, the hooks run this executable.
assert!(value["hooks"][0]["entry"]
.to_string()
assert_eq!(hooks[0]["format"], "json");
assert!(hooks[0]["text"]
.as_str()
.unwrap()
.contains("managed apply --hook fastskill-managed-claude"));
assert_eq!(hooks[1]["format"], "toml");
assert!(hooks[1]["text"]
.as_str()
.unwrap()
.contains("[[hooks.SessionStart]]"));
assert!(hooks[3]["file"].is_null() && hooks[3]["text"].is_null());

let setup = hook_setup(claude_admin);
for os in [TimerOs::Linux, TimerOs::MacOs, TimerOs::Windows] {
let text = render_system(&system_json(&setup, Some(os), &env.exe));
assert!(text.contains("claude: merge into "), "{text}");
assert!(text.contains("(already present)"), "{text}");
assert!(text.contains("codex: add to "), "{text}");
assert!(text.contains("[[hooks.SessionStart]]"), "{text}");
assert!(text.contains("cursor: merge into "), "{text}");
assert!(
text.contains("pi: no administrator-level location; the machine-wide timer covers it"),
"{text}"
);
assert!(!text.contains("gemini"), "{text}");
assert!(text.contains("Machine-wide timer:\n"), "{text}");
// A machine-wide launch agent loads at each user's next login.
assert_eq!(text.contains("then run: "), os != TimerOs::MacOs, "{text}");
Expand Down
Loading
Loading