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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ SessionStart / ToolUse / 每日任务 / 用户命令
- Hook 热路径不联网、不合并、不调用模型,SQLite 使用 `busy_timeout=0`;数据库繁忙或输入异常时 fail-open。
- `kick` 只启动一个脱离当前会话的一次性 worker。全局文件锁保证并发 Session 不会并行更新。
- macOS 使用 launchd,Linux 使用 systemd user timer;两者每天启动一次 `reconcile --once`,没有常驻 ToolTend 进程。
- 每轮 reconcile 都会持久化完整运行状态;主任务之后由独立 watchdog 检查漏跑、失败或未完成状态。失败默认发送桌面通知,并在下次 Codex/Claude SessionStart 时补充提醒。
- macOS 安装器会用 Xcode Command Line Tools 构建并把 `ToolTend Notifier.app` 注册到 `~/Applications`,首次发送时需要在系统提示中允许通知;不再借用 Script Editor 的通知身份,`tooltend doctor` 也会检查安装与授权状态。
- macOS 调度输出保存在 `~/.local/state/tooltend/logs/`,不会再丢弃到 `/dev/null`;`tooltend status` 和 `tooltend doctor` 会显示最近一次完整 reconcile 的结果。
- 未执行 `bundles configure` 的 Bundle 不检查更新、不下载,也不调用安装器。
- Bundle 更新先完成所有 Artifact 的解析、校验和 staging,再按物理 Installation 激活;失败时按相反顺序补偿。
- Bundle 事务使用步骤 journal。中断、失败、回滚和健康检查都有 Bundle 级 Receipt 可审计。
Expand Down
6 changes: 6 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ if [[ -f "$target" && ! -L "$target" ]]; then
fi
mv -f "$binary" "$target"

if [[ "$os_name" == "darwin" ]]; then
if ! "$target" __notifier install; then
echo "ToolTend was installed, but macOS notifications need Xcode Command Line Tools. Install them and run: tooltend __notifier install" >&2
fi
fi

echo "Installed tooltend to $target"
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
Expand Down
53 changes: 53 additions & 0 deletions internal/bundle/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,25 @@ func Discover(ctx context.Context, database *store.Store, options DiscoverOption
match := matchedFromObserved(*item, artifact, options.HomeDir)
enrichSkillMatch(&match, *item, skillEvidence)
enrichWorkspaceMatch(&match, *item)
enrichRecipeSource(&match, artifact)
matches[artifact.Key] = append(matches[artifact.Key], match)
matchedBindings[item.binding.ID] = struct{}{}
}
}
for _, probe := range artifact.Probes {
match, ok := resolveProbe(ctx, probe, recipe, artifact, options, lookup)
if ok {
enrichRecipeSource(&match, artifact)
matches[artifact.Key] = append(matches[artifact.Key], match)
}
}
if artifact.Driver == "mainline-hooks" {
hookMatches, hookErr := discoverMainlineHooks(ctx, database)
if hookErr != nil {
return DiscoverResult{}, hookErr
}
matches[artifact.Key] = append(matches[artifact.Key], hookMatches...)
}
matches[artifact.Key] = dedupeMatches(matches[artifact.Key])
}
if countMatches(matches) == 0 {
Expand Down Expand Up @@ -588,6 +597,50 @@ func enrichWorkspaceMatch(match *matchedInstallation, observed observedInstallat
}
}

func enrichRecipeSource(match *matchedInstallation, artifact ArtifactRecipe) {
if strings.TrimSpace(artifact.Source) == "" {
return
}
match.sourceIdentity = strings.TrimSpace(artifact.Source)
if artifact.Subdir != "" {
match.sourceIdentity += "#" + filepath.ToSlash(filepath.Clean(artifact.Subdir))
}
match.metadata["recipe_source_url"] = strings.TrimSpace(artifact.Source)
if artifact.Subdir != "" {
match.metadata["recipe_source_subdir"] = filepath.ToSlash(filepath.Clean(artifact.Subdir))
}
}

func discoverMainlineHooks(ctx context.Context, database *store.Store) ([]matchedInstallation, error) {
projects, err := database.ListProjects(ctx)
if err != nil {
return nil, err
}
var result []matchedInstallation
for _, project := range projects {
if !project.Selected {
continue
}
root := filepath.Clean(project.RootPath)
if info, statErr := os.Stat(filepath.Join(root, ".mainline", "config.toml")); statErr != nil || !info.Mode().IsRegular() {
continue
}
digest := sha256.New()
for _, relative := range []string{".claude/settings.json", ".codex/config.toml", ".codex/hooks.json", ".cursor/hooks.json"} {
data, readErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(relative)))
if readErr == nil {
_, _ = digest.Write([]byte(relative + "\x00"))
_, _ = digest.Write(data)
}
}
result = append(result, matchedInstallation{
path: root, packageIdentity: "mainline-hooks", sourceIdentity: "mainline-hooks:" + root,
hash: hex.EncodeToString(digest.Sum(nil)), metadata: map[string]any{"project_id": project.ID, "derived": true},
})
}
return result, nil
}

func inspectSignedSkillManifest(skillPath string) (string, bool, bool) {
manifestPath := filepath.Join(skillPath, "skill.manifest")
data, err := os.ReadFile(manifestPath)
Expand Down
33 changes: 33 additions & 0 deletions internal/bundle/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,39 @@ func TestDiscoverDeduplicatesPhysicalInstallAndReadsPackageMetadata(t *testing.T
}
}

func TestDiscoverMainlineHooksUsesSelectedRepositories(t *testing.T) {
database, err := store.OpenRW(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
ctx := context.Background()
selected := t.TempDir()
ignored := t.TempDir()
for _, root := range []string{selected, ignored} {
if err := os.MkdirAll(filepath.Join(root, ".mainline"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".mainline", "config.toml"), []byte("[hooks]\nenabled=true\n"), 0o644); err != nil {
t.Fatal(err)
}
}
now := time.Now().UTC()
if err := database.UpsertProject(ctx, model.Project{ID: "selected", RootPath: selected, RootFingerprint: "selected", Selected: true, DiscoveredVia: "test", LastSeenAt: now}); err != nil {
t.Fatal(err)
}
if err := database.UpsertProject(ctx, model.Project{ID: "ignored", RootPath: ignored, RootFingerprint: "ignored", Selected: false, DiscoveredVia: "test", LastSeenAt: now}); err != nil {
t.Fatal(err)
}
matches, err := discoverMainlineHooks(ctx, database)
if err != nil {
t.Fatal(err)
}
if len(matches) != 1 || matches[0].path != selected || matches[0].packageIdentity != "mainline-hooks" {
t.Fatalf("matches = %#v", matches)
}
}

func TestDiscoverPrunesStaleProbeWhenBindingProvidesRicherEvidence(t *testing.T) {
database, err := store.OpenRW(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions internal/bundle/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type ArtifactRecipe struct {
Name string `toml:"name" json:"name"`
Kind model.ArtifactKind `toml:"kind" json:"kind"`
Driver string `toml:"driver" json:"driver"`
Source string `toml:"source" json:"source,omitempty"`
Subdir string `toml:"subdir" json:"subdir,omitempty"`
Required bool `toml:"required" json:"required"`
Selectors []Selector `toml:"selectors" json:"selectors"`
Probes []string `toml:"probes" json:"probes,omitempty"`
Expand Down Expand Up @@ -184,6 +186,12 @@ func (r Recipe) Validate() error {
if err := artifact.Kind.Validate(); err != nil {
return err
}
if strings.ContainsAny(artifact.Source+artifact.Subdir, "\x00\r\n") {
return fmt.Errorf("artifact %s source contains invalid characters", artifact.Key)
}
if artifact.Subdir != "" && (filepath.IsAbs(artifact.Subdir) || strings.HasPrefix(filepath.Clean(artifact.Subdir), "..")) {
return fmt.Errorf("artifact %s source subdirectory must be relative", artifact.Key)
}
for _, selector := range artifact.Selectors {
if err := selector.Validate(); err != nil {
return fmt.Errorf("artifact %s: %w", artifact.Key, err)
Expand Down
15 changes: 13 additions & 2 deletions internal/bundle/recipes/agent-capsule.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "agent-capsule"
version = "1"
version = "2"
name = "Agent Capsule"
owner = "delegated"
confidence = "high"
Expand All @@ -13,7 +13,11 @@ kind = "cli"
driver = "github-release"
required = true
probes = ["command:capsule"]
health_argv = ["capsule", "version"]
resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "z2z23n0/agent-capsule"]
stage_argv = ["tooltend", "__bundle-driver", "github-stage", "z2z23n0/agent-capsule", "capsule", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "github-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "github-rollback", "z2z23n0/agent-capsule", "capsule", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "binary-health", "${path}", "help"]
[[artifacts.selectors]]
field = "name"
equals = "capsule"
Expand All @@ -23,7 +27,14 @@ key = "skill"
name = "Agent Capsule Skill"
kind = "skill"
driver = "git-skill"
source = "https://github.com/z2z23n0/agent-capsule.git"
subdir = "skills/agent-capsule"
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-release-resolve", "https://github.com/z2z23n0/agent-capsule.git", "z2z23n0/agent-capsule"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/z2z23n0/agent-capsule.git", "skills/agent-capsule", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/z2z23n0/agent-capsule.git", "skills/agent-capsule", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "agent-capsule"
9 changes: 8 additions & 1 deletion internal/bundle/recipes/anysearch.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "anysearch"
version = "1"
version = "2"
name = "AnySearch"
owner = "delegated"
confidence = "high"
Expand All @@ -9,7 +9,14 @@ key = "skill"
name = "AnySearch Skill"
kind = "skill"
driver = "git-skill"
source = "https://github.com/catoncat/anysearch-skill.git"
subdir = "anysearch"
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/anysearch-skill.git"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/anysearch-skill.git", "anysearch", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/anysearch-skill.git", "anysearch", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "anysearch"
9 changes: 8 additions & 1 deletion internal/bundle/recipes/codex-conductor.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "codex-conductor"
version = "1"
version = "2"
name = "Codex Conductor"
owner = "delegated"
confidence = "high"
Expand All @@ -9,7 +9,14 @@ key = "skill"
name = "Codex Conductor Skill"
kind = "skill"
driver = "git-skill"
source = "https://github.com/catoncat/codex-conductor.git"
subdir = "."
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/codex-conductor.git"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/codex-conductor.git", ".", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/codex-conductor.git", ".", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "codex-conductor"
9 changes: 8 additions & 1 deletion internal/bundle/recipes/known-skills.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "0g-hk"
version = "1"
version = "2"
name = "0g-hk"
owner = "delegated"
confidence = "high"
Expand All @@ -10,7 +10,14 @@ key = "skill"
name = "0g-hk Skill"
kind = "skill"
driver = "git-skill"
source = "https://github.com/catoncat/0g-hk.git"
subdir = "skill-packages/0g-hk"
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/0g-hk.git"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/0g-hk.git", "skill-packages/0g-hk", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/0g-hk.git", "skill-packages/0g-hk", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "0g-hk"
20 changes: 18 additions & 2 deletions internal/bundle/recipes/mainline.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "mainline"
version = "1"
version = "2"
name = "Mainline"
owner = "delegated"
confidence = "high"
Expand All @@ -13,7 +13,11 @@ kind = "cli"
driver = "github-release"
required = true
probes = ["command:mainline"]
health_argv = ["mainline", "version"]
resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "mainline-org/mainline"]
stage_argv = ["tooltend", "__bundle-driver", "github-stage", "mainline-org/mainline", "mainline", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "github-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "github-rollback", "mainline-org/mainline", "mainline", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "binary-health", "${path}", "version"]
[[artifacts.selectors]]
field = "name"
equals = "mainline"
Expand All @@ -26,7 +30,14 @@ key = "skill"
name = "Mainline Skill"
kind = "skill"
driver = "npx-skills"
source = "https://github.com/mainline-org/mainline.git"
subdir = "skills/mainline"
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-release-resolve", "https://github.com/mainline-org/mainline.git", "mainline-org/mainline"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/mainline-org/mainline.git", "skills/mainline", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/mainline-org/mainline.git", "skills/mainline", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "mainline"
Expand All @@ -40,3 +51,8 @@ name = "Mainline generated hooks"
kind = "hook"
driver = "mainline-hooks"
required = false
resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "mainline-org/mainline"]
stage_argv = ["tooltend", "__bundle-driver", "mainline-hooks-stage", "${path}", "${stage}"]
activate_argv = ["tooltend", "__bundle-driver", "mainline-hooks-activate", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "mainline-hooks-rollback", "${path}", "${stage}"]
health_argv = ["tooltend", "__bundle-driver", "mainline-hooks-health", "${path}"]
15 changes: 13 additions & 2 deletions internal/bundle/recipes/sherlog.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "sherlog"
version = "1"
version = "2"
name = "Sherlog"
owner = "delegated"
confidence = "high"
Expand All @@ -13,7 +13,11 @@ kind = "cli"
driver = "npm"
required = true
probes = ["command:sherlog", "command:shlog"]
health_argv = ["sherlog", "--version"]
resolve_argv = ["tooltend", "__bundle-driver", "npm-resolve", "@act0r/sherlog"]
stage_argv = ["tooltend", "__bundle-driver", "npm-stage", "@act0r/sherlog", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "npm-activate", "${stage}"]
rollback_argv = ["tooltend", "__bundle-driver", "npm-rollback", "@act0r/sherlog", "${rollback_version}", "${stage}"]
health_argv = ["shlog", "--version"]
[[artifacts.selectors]]
field = "name"
equals = "sherlog|shlog"
Expand All @@ -26,7 +30,14 @@ key = "skill"
name = "Sherlog Skill"
kind = "skill"
driver = "npx-skills"
source = "https://github.com/catoncat/sherlog.git"
subdir = "skill-packages/sherlog"
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-npm-release-resolve", "https://github.com/catoncat/sherlog.git", "@act0r/sherlog"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/sherlog.git", "skill-packages/sherlog", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/sherlog.git", "skill-packages/sherlog", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "sherlog"
Expand Down
9 changes: 8 additions & 1 deletion internal/bundle/recipes/shuorenhua.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema = "bundle-recipe-v1"
id = "shuorenhua"
version = "1"
version = "2"
name = "shuorenhua"
owner = "delegated"
confidence = "high"
Expand All @@ -9,7 +9,14 @@ key = "skill"
name = "shuorenhua Skill"
kind = "skill"
driver = "git-skill"
source = "https://github.com/MrGeDiao/shuorenhua.git"
subdir = "."
required = true
resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/MrGeDiao/shuorenhua.git"]
stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/MrGeDiao/shuorenhua.git", ".", "${version}", "${previous_version}", "${stage}", "${path}"]
activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"]
rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/MrGeDiao/shuorenhua.git", ".", "${rollback_version}", "${stage}", "${path}"]
health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"]
[[artifacts.selectors]]
field = "name"
equals = "shuorenhua"
Loading
Loading