mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Merge pull request #12 from runbear-io/feat/session-notes-agent-hooks
Folder views, session-linked notes, and agent-agnostic sync hooks
This commit is contained in:
@@ -121,7 +121,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
|
||||
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
|
||||
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
|
||||
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires |
|
||||
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping; idempotent (`--agent` overrides detection) |
|
||||
| `bdrive status [folder]` | Projects, daemon state, pending changes |
|
||||
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
|
||||
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
|
||||
|
||||
+16
-1
@@ -13,7 +13,9 @@ import (
|
||||
)
|
||||
|
||||
func syncCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var note string
|
||||
var noteTTL time.Duration
|
||||
c := &cobra.Command{
|
||||
Use: "sync [folder]",
|
||||
Short: "Sync a mounted folder with its remote now",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -27,6 +29,16 @@ func syncCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
defer closeSession(sess)
|
||||
if cmd.Flags().Changed("note") {
|
||||
// Persist the note so the daemon's own scans stamp it too —
|
||||
// history then links every change from this working session
|
||||
// to its context, not just the ones this invocation catches.
|
||||
// An explicit empty --note clears it. Expires after --note-ttl.
|
||||
if err := sess.Store.SaveNote(note, noteTTL); err != nil {
|
||||
return err
|
||||
}
|
||||
sess.Note = note
|
||||
}
|
||||
sess.OnProgress = progressReporter()
|
||||
res, err := sess.Cycle(cmd.Context())
|
||||
if err != nil {
|
||||
@@ -37,6 +49,9 @@ func syncCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(¬e, "note", "", "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears")
|
||||
c.Flags().DurationVar(¬eTTL, "note-ttl", 30*time.Minute, "how long the note keeps applying to daemon-committed changes")
|
||||
return c
|
||||
}
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/agenthooks"
|
||||
)
|
||||
|
||||
// bdrive hooks — register BearDrive's turn-boundary sync hooks with whatever
|
||||
// AI agent platforms the user works with (Claude Code, Codex, Gemini CLI,
|
||||
// Hermes). One command instead of hand-editing four config formats; the
|
||||
// beardrive skill runs it right after `bdrive init`.
|
||||
func hooksCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "hooks",
|
||||
Short: "Show which AI agent platforms have beardrive sync hooks registered",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detected := map[string]bool{}
|
||||
for _, a := range agenthooks.Detect(folder) {
|
||||
detected[a] = true
|
||||
}
|
||||
for _, a := range agenthooks.Agents {
|
||||
state := "not detected"
|
||||
if detected[a] {
|
||||
state = "detected, hooks not registered"
|
||||
if agenthooks.Registered(folder, a) {
|
||||
state = "hooks registered"
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %-8s %-32s %s\n", a, state, agenthooks.ConfigPath(folder, a))
|
||||
}
|
||||
fmt.Println("\nregister with: bdrive hooks install [--agent claude,codex,gemini,hermes]")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var agentsFlag string
|
||||
install := &cobra.Command{
|
||||
Use: "install [folder]",
|
||||
Short: "Register sync hooks for detected agent platforms (or --agent list)",
|
||||
Long: "Registers beardrive's sync hooks with each agent platform's own hook\n" +
|
||||
"config: files pull before every turn and push after edits, and changes\n" +
|
||||
"are stamped with the agent session that made them (`bdrive sync --note`).\n" +
|
||||
"Merging is idempotent and preserves hooks you already have.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var agents []string
|
||||
if agentsFlag != "" && agentsFlag != "auto" {
|
||||
agents = strings.Split(agentsFlag, ",")
|
||||
}
|
||||
results, err := agenthooks.Install(folder, agents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fmt.Println("no agent platforms detected (looked for .claude/, .codex/, .gemini/ here or in ~; ~/.hermes/)")
|
||||
fmt.Println("pick explicitly: bdrive hooks install --agent claude,codex,gemini,hermes")
|
||||
return nil
|
||||
}
|
||||
for _, r := range results {
|
||||
state := "already registered"
|
||||
if r.Changed {
|
||||
state = "registered"
|
||||
}
|
||||
fmt.Printf(" %-8s %s → %s\n", r.Agent, state, r.Path)
|
||||
if r.Note != "" {
|
||||
fmt.Printf(" note: %s\n", r.Note)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
install.Flags().StringVar(&agentsFlag, "agent", "auto", "comma-separated platforms (claude,codex,gemini,hermes) or auto")
|
||||
c.AddCommand(install)
|
||||
return c
|
||||
}
|
||||
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
shareCmd(),
|
||||
stopCmd(),
|
||||
syncCmd(),
|
||||
hooksCmd(),
|
||||
statusCmd(),
|
||||
logCmd(),
|
||||
webCmd(),
|
||||
|
||||
@@ -16,6 +16,7 @@ require (
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sync v0.21.0
|
||||
google.golang.org/api v0.284.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
@@ -146,6 +150,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
@@ -243,6 +249,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package agenthooks detects which AI agent platforms a user works with and
|
||||
// registers BearDrive's sync hooks in each platform's own hook config, so
|
||||
// files sync at turn boundaries no matter which agent edits them.
|
||||
//
|
||||
// Every supported platform runs command hooks the same way — spawn a shell
|
||||
// command, pipe event JSON (with a session_id) on stdin — so one hook command
|
||||
// works everywhere; only the config file format and event names differ:
|
||||
//
|
||||
// claude .claude/settings.json UserPromptSubmit / PostToolUse (project)
|
||||
// codex .codex/hooks.json UserPromptSubmit / PostToolUse (project)
|
||||
// gemini .gemini/settings.json BeforeAgent / AfterTool (project)
|
||||
// hermes ~/.hermes/config.yaml pre_llm_call / post_tool_call (user)
|
||||
//
|
||||
// The hook syncs the project and stamps changes with "<agent> session <id>"
|
||||
// (see `bdrive sync --note`), so hub history links every change to the agent
|
||||
// session that made it. Hooks are fast no-ops outside bdrive projects.
|
||||
package agenthooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
|
||||
// marker identifies our hooks inside a config, for idempotency and status.
|
||||
const marker = "bdrive sync"
|
||||
|
||||
// Agent names, in the order they are reported.
|
||||
var Agents = []string{"claude", "codex", "gemini", "hermes"}
|
||||
|
||||
// Result reports what Install did for one agent platform.
|
||||
type Result struct {
|
||||
Agent string
|
||||
Path string // config file the hooks live in
|
||||
Changed bool // false = already registered
|
||||
Note string // extra step the user must take, if any
|
||||
}
|
||||
|
||||
// hookCommand is the one shell command every platform runs: sync the project
|
||||
// if it is a bdrive mount, stamping changes with the agent session id parsed
|
||||
// from the hook's stdin JSON. POSIX sh only — no jq, no bashisms.
|
||||
func hookCommand(label string) string {
|
||||
return `sh -c '` +
|
||||
`cd "${CLAUDE_PROJECT_DIR:-.}" && [ -d .bdrive ] && command -v bdrive >/dev/null || exit 0; ` +
|
||||
`s=; [ -t 0 ] || s=$(head -c 8192 2>/dev/null | tr -d \" | sed -n "s/.*session_id[[:space:]]*:[[:space:]]*\([a-zA-Z0-9_-]*\).*/\1/p" | head -n 1); ` +
|
||||
`if [ -n "$s" ]; then bdrive sync . --note "` + label + ` session $s" >/dev/null 2>&1 || true; ` +
|
||||
`else bdrive sync . >/dev/null 2>&1 || true; fi'`
|
||||
}
|
||||
|
||||
type platform struct {
|
||||
label string // session-note label
|
||||
projectDir string // presence of this dir (project or home) = detected
|
||||
userLevel bool // config lives in the home dir, not the project
|
||||
install func(folder string) (path string, changed bool, err error)
|
||||
note string
|
||||
}
|
||||
|
||||
var platforms = map[string]platform{
|
||||
"claude": {
|
||||
label: "claude-code",
|
||||
projectDir: ".claude",
|
||||
install: func(folder string) (string, bool, error) {
|
||||
return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "claude-code", 30, true)
|
||||
},
|
||||
},
|
||||
"codex": {
|
||||
label: "codex",
|
||||
projectDir: ".codex",
|
||||
install: func(folder string) (string, bool, error) {
|
||||
return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "apply_patch", "codex", 30, false)
|
||||
},
|
||||
note: "run /hooks inside Codex once to trust the project's .codex layer",
|
||||
},
|
||||
"gemini": {
|
||||
label: "gemini",
|
||||
projectDir: ".gemini",
|
||||
install: func(folder string) (string, bool, error) {
|
||||
// Gemini uses its own event names and millisecond timeouts.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"),
|
||||
"BeforeAgent", "AfterTool", "write_file|replace|edit", "gemini", 30000, false)
|
||||
},
|
||||
},
|
||||
"hermes": {
|
||||
label: "hermes",
|
||||
userLevel: true,
|
||||
install: installHermes,
|
||||
},
|
||||
}
|
||||
|
||||
// Detect reports which agent platforms are in use, judged by their config
|
||||
// dirs existing in the project or the home directory.
|
||||
func Detect(folder string) []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
var found []string
|
||||
for _, name := range Agents {
|
||||
p := platforms[name]
|
||||
switch {
|
||||
case p.userLevel:
|
||||
if dirExists(filepath.Join(home, "."+name)) {
|
||||
found = append(found, name)
|
||||
}
|
||||
case dirExists(filepath.Join(folder, p.projectDir)) ||
|
||||
(home != "" && dirExists(filepath.Join(home, p.projectDir))):
|
||||
found = append(found, name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Registered reports whether an agent's config already carries our hooks.
|
||||
func Registered(folder, agent string) bool {
|
||||
data, err := os.ReadFile(ConfigPath(folder, agent))
|
||||
return err == nil && strings.Contains(string(data), marker)
|
||||
}
|
||||
|
||||
// ConfigPath returns where an agent's hooks are (or would be) registered.
|
||||
func ConfigPath(folder, agent string) string {
|
||||
switch agent {
|
||||
case "claude":
|
||||
return filepath.Join(folder, ".claude", "settings.json")
|
||||
case "codex":
|
||||
return filepath.Join(folder, ".codex", "hooks.json")
|
||||
case "gemini":
|
||||
return filepath.Join(folder, ".gemini", "settings.json")
|
||||
case "hermes":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".hermes", "config.yaml")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Install registers the sync hooks for the given agents ("auto"/empty =
|
||||
// every detected platform). Merging is idempotent and preserves whatever
|
||||
// hooks the config already has.
|
||||
func Install(folder string, agents []string) ([]Result, error) {
|
||||
if len(agents) == 0 || (len(agents) == 1 && agents[0] == "auto") {
|
||||
agents = Detect(folder)
|
||||
}
|
||||
var out []Result
|
||||
for _, name := range agents {
|
||||
p, ok := platforms[name]
|
||||
if !ok {
|
||||
return out, fmt.Errorf("unknown agent %q (supported: %s)", name, strings.Join(Agents, ", "))
|
||||
}
|
||||
path, changed, err := p.install(folder)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
out = append(out, Result{Agent: name, Path: path, Changed: changed, Note: p.note})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeJSONHooks adds the pull + push hook pair to a Claude-style hooks JSON
|
||||
// file (Claude, Codex, and Gemini all use this shape: hooks.<Event> is an
|
||||
// array of {matcher?, hooks: [{type: "command", ...}]} groups).
|
||||
func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, label string, timeout int, async bool) (string, bool, error) {
|
||||
root := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return path, false, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return path, false, err
|
||||
}
|
||||
hooks, ok := root["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
hooks = map[string]any{}
|
||||
root["hooks"] = hooks
|
||||
}
|
||||
cmd := hookCommand(label)
|
||||
pull := map[string]any{"hooks": []any{map[string]any{
|
||||
"type": "command", "command": cmd, "timeout": timeout,
|
||||
"statusMessage": "beardrive: pulling latest files",
|
||||
}}}
|
||||
pushHook := map[string]any{"type": "command", "command": cmd, "timeout": timeout}
|
||||
if async {
|
||||
pushHook["async"] = true
|
||||
}
|
||||
push := map[string]any{"matcher": pushMatcher, "hooks": []any{pushHook}}
|
||||
|
||||
changed := false
|
||||
for event, group := range map[string]any{pullEvent: pull, pushEvent: push} {
|
||||
arr, _ := hooks[event].([]any)
|
||||
if containsMarker(arr) {
|
||||
continue
|
||||
}
|
||||
hooks[event] = append(arr, group)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return path, false, nil
|
||||
}
|
||||
return path, true, writeConfig(path, func() ([]byte, error) {
|
||||
return json.MarshalIndent(root, "", " ")
|
||||
})
|
||||
}
|
||||
|
||||
// installHermes merges the hook pair into ~/.hermes/config.yaml
|
||||
// (hooks.<event> is an array of {matcher?, command, timeout}).
|
||||
func installHermes(string) (string, bool, error) {
|
||||
path := ConfigPath("", "hermes")
|
||||
root := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
return path, false, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return path, false, err
|
||||
}
|
||||
hooks, ok := root["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
hooks = map[string]any{}
|
||||
root["hooks"] = hooks
|
||||
}
|
||||
cmd := hookCommand("hermes")
|
||||
groups := map[string]any{
|
||||
"pre_llm_call": map[string]any{"command": cmd, "timeout": 30},
|
||||
"post_tool_call": map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30},
|
||||
}
|
||||
changed := false
|
||||
for event, group := range groups {
|
||||
arr, _ := hooks[event].([]any)
|
||||
if containsMarker(arr) {
|
||||
continue
|
||||
}
|
||||
hooks[event] = append(arr, group)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return path, false, nil
|
||||
}
|
||||
return path, true, writeConfig(path, func() ([]byte, error) {
|
||||
return yaml.Marshal(root)
|
||||
})
|
||||
}
|
||||
|
||||
// containsMarker reports whether a hook array already holds one of ours.
|
||||
// Serializing sidesteps walking every platform's nesting by hand.
|
||||
func containsMarker(v any) bool {
|
||||
data, err := json.Marshal(v)
|
||||
return err == nil && strings.Contains(string(data), marker)
|
||||
}
|
||||
|
||||
func writeConfig(path string, marshal func() ([]byte, error)) error {
|
||||
data, err := marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.WriteFileAtomic(path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package agenthooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func readJSON(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("%s is not valid JSON: %v", path, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
folder := t.TempDir()
|
||||
|
||||
if got := Detect(folder); len(got) != 0 {
|
||||
t.Fatalf("nothing configured, detected %v", got)
|
||||
}
|
||||
// project-level dirs
|
||||
os.MkdirAll(filepath.Join(folder, ".codex"), 0o755)
|
||||
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
|
||||
// home-level dirs
|
||||
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
|
||||
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
|
||||
got := Detect(folder)
|
||||
want := []string{"claude", "codex", "gemini", "hermes"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("detected %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallJSONPlatforms(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
|
||||
results, err := Install(folder, []string{"claude", "codex", "gemini"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Fatalf("%s: fresh install reported unchanged", r.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Claude: both events present, push is async, command carries the label.
|
||||
cl := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
|
||||
hooks := cl["hooks"].(map[string]any)
|
||||
for _, ev := range []string{"UserPromptSubmit", "PostToolUse"} {
|
||||
if _, ok := hooks[ev]; !ok {
|
||||
t.Fatalf("claude missing %s", ev)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(cl)
|
||||
if !strings.Contains(string(raw), "claude-code session $s") {
|
||||
t.Fatal("claude hook lacks its session-note label")
|
||||
}
|
||||
if !strings.Contains(string(raw), `"async":true`) {
|
||||
t.Fatal("claude push hook should be async")
|
||||
}
|
||||
|
||||
// Codex: same schema, its own label and matcher, no async field.
|
||||
cx, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".codex", "hooks.json")))
|
||||
if !strings.Contains(string(cx), "codex session $s") || !strings.Contains(string(cx), "apply_patch") {
|
||||
t.Fatalf("codex hooks wrong: %s", cx)
|
||||
}
|
||||
if strings.Contains(string(cx), "async") {
|
||||
t.Fatal("codex should not get the claude-only async field")
|
||||
}
|
||||
|
||||
// Gemini: its own event names and ms timeout.
|
||||
gm, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".gemini", "settings.json")))
|
||||
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000"} {
|
||||
if !strings.Contains(string(gm), want) {
|
||||
t.Fatalf("gemini hooks missing %q: %s", want, gm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallIdempotentAndPreserving(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
|
||||
// Pre-existing user hook must survive the merge.
|
||||
pre := `{"permissions":{"allow":["Bash(ls:*)"]},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}]}}`
|
||||
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755)
|
||||
os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(pre), 0o644)
|
||||
|
||||
if _, err := Install(folder, []string{"claude"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
|
||||
raw, _ := json.Marshal(cfg)
|
||||
if !strings.Contains(string(raw), "echo mine") {
|
||||
t.Fatal("merge dropped the user's existing hook")
|
||||
}
|
||||
if _, ok := cfg["permissions"]; !ok {
|
||||
t.Fatal("merge dropped unrelated settings keys")
|
||||
}
|
||||
if got := len(cfg["hooks"].(map[string]any)["PostToolUse"].([]any)); got != 2 {
|
||||
t.Fatalf("PostToolUse groups = %d, want user's + ours", got)
|
||||
}
|
||||
|
||||
// Second install: no change, byte-identical file.
|
||||
before, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json"))
|
||||
results, err := Install(folder, []string{"claude"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if results[0].Changed {
|
||||
t.Fatal("re-install reported a change")
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(folder, ".claude", "settings.json"))
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("re-install rewrote the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallHermesYAML(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
os.MkdirAll(filepath.Join(home, ".hermes"), 0o755)
|
||||
// Existing config keys must survive.
|
||||
os.WriteFile(filepath.Join(home, ".hermes", "config.yaml"),
|
||||
[]byte("model: hermes-4\nhooks_auto_accept: false\n"), 0o644)
|
||||
|
||||
results, err := Install(t.TempDir(), []string{"hermes"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !results[0].Changed {
|
||||
t.Fatal("fresh hermes install reported unchanged")
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(home, ".hermes", "config.yaml"))
|
||||
var m map[string]any
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("config.yaml no longer parses: %v", err)
|
||||
}
|
||||
if m["model"] != "hermes-4" {
|
||||
t.Fatal("merge dropped existing hermes config")
|
||||
}
|
||||
hooks := m["hooks"].(map[string]any)
|
||||
for _, ev := range []string{"pre_llm_call", "post_tool_call"} {
|
||||
if _, ok := hooks[ev]; !ok {
|
||||
t.Fatalf("hermes missing %s", ev)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(data), "hermes session $s") {
|
||||
t.Fatal("hermes hook lacks its session-note label")
|
||||
}
|
||||
|
||||
// Idempotent.
|
||||
results, _ = Install(t.TempDir(), []string{"hermes"})
|
||||
if results[0].Changed {
|
||||
t.Fatal("hermes re-install reported a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAutoUsesDetection(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(folder, ".gemini"), 0o755)
|
||||
|
||||
results, err := Install(folder, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Agent != "gemini" {
|
||||
t.Fatalf("auto install = %+v, want just gemini", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUnknownAgent(t *testing.T) {
|
||||
if _, err := Install(t.TempDir(), []string{"cursor"}); err == nil {
|
||||
t.Fatal("unknown agent should error")
|
||||
}
|
||||
}
|
||||
|
||||
// The generated hook command must extract a session id from hook stdin JSON
|
||||
// and invoke bdrive with the platform label — run it for real against a fake
|
||||
// bdrive to pin the shell behavior on every platform's payload shape.
|
||||
func TestHookCommandExtraction(t *testing.T) {
|
||||
if _, err := os.Stat("/bin/sh"); err != nil {
|
||||
t.Skip("no /bin/sh")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, ".bdrive"), 0o755)
|
||||
bin := filepath.Join(dir, "bin")
|
||||
os.MkdirAll(bin, 0o755)
|
||||
fake := "#!/bin/sh\necho \"$@\" > \"" + dir + "/args.txt\"\n"
|
||||
os.WriteFile(filepath.Join(bin, "bdrive"), []byte(fake), 0o755)
|
||||
|
||||
payloads := map[string]string{
|
||||
"claude-code": `{"session_id":"abc-123","hook_event_name":"UserPromptSubmit"}`,
|
||||
"codex": `{"session_id":"th_042","turn_id":"t1","cwd":"/x"}`,
|
||||
"gemini": `{"session_id":"g-9f","timestamp":"2026-07-11T00:00:00Z"}`,
|
||||
"hermes": `{"hook_event_name":"pre_llm_call","tool_name":null,"session_id":"sess_abc123"}`,
|
||||
}
|
||||
for label, payload := range payloads {
|
||||
os.Remove(filepath.Join(dir, "args.txt"))
|
||||
cmdline := hookCommand(label)
|
||||
sh := "cd " + dir + " && PATH=" + bin + ":$PATH " + cmdline
|
||||
if err := runShell(t, sh, payload); err != nil {
|
||||
t.Fatalf("%s: %v", label, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "args.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: hook never called bdrive: %v", label, err)
|
||||
}
|
||||
want := "sync . --note " + label + " session "
|
||||
if !strings.Contains(string(got), want) {
|
||||
t.Fatalf("%s: bdrive argv = %q, want it to contain %q", label, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runShell(t *testing.T, script, stdin string) error {
|
||||
t.Helper()
|
||||
cmd := exec.Command("/bin/sh", "-c", script)
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The session note is transient per-volume context — "why edits are being
|
||||
// made right now" (e.g. a Claude Code session id set by the plugin's sync
|
||||
// hook). Whichever scanner commits an op while the note is live (the daemon
|
||||
// or a one-shot `bdrive sync`) stamps it into Op.Note, so provenance doesn't
|
||||
// depend on winning the race against the daemon's scan interval. The TTL
|
||||
// keeps a stale note from mislabeling unrelated edits made hours later.
|
||||
|
||||
// Note is the on-disk shape of the session note.
|
||||
type Note struct {
|
||||
Text string `json:"text"`
|
||||
Expires time.Time `json:"expires,omitzero"` // zero = never expires
|
||||
}
|
||||
|
||||
func (s *Store) notePath() string { return filepath.Join(s.dir, "note.json") }
|
||||
|
||||
// SaveNote sets the session note. ttl > 0 bounds its life; empty text clears.
|
||||
func (s *Store) SaveNote(text string, ttl time.Duration) error {
|
||||
if text == "" {
|
||||
return s.ClearNote()
|
||||
}
|
||||
n := Note{Text: text}
|
||||
if ttl > 0 {
|
||||
n.Expires = time.Now().Add(ttl)
|
||||
}
|
||||
return WriteJSONAtomic(s.notePath(), n)
|
||||
}
|
||||
|
||||
// ClearNote removes the session note.
|
||||
func (s *Store) ClearNote() error {
|
||||
err := os.Remove(s.notePath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadNote returns the live session note, or "" if none is set or it has
|
||||
// expired. Never errors: a missing or unreadable note is just no note.
|
||||
func (s *Store) LoadNote() string {
|
||||
var n Note
|
||||
if err := readJSON(s.notePath(), &n); err != nil {
|
||||
return ""
|
||||
}
|
||||
if !n.Expires.IsZero() && time.Now().After(n.Expires) {
|
||||
return ""
|
||||
}
|
||||
return n.Text
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/config"
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
"github.com/runbear-io/beardrive/internal/remote"
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
@@ -207,3 +208,70 @@ func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) {
|
||||
t.Fatalf("conflict copy name %q should sanitize device names", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Session-linked notes: a note set for the session (explicitly, or persisted
|
||||
// in the store by `bdrive sync --note`) is stamped onto every op that scan
|
||||
// commits, travels through the remote to peers, and expires with its TTL —
|
||||
// so history can link each change to the agent session that made it.
|
||||
func TestSessionNoteStampsOps(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
// Explicit note on the session (one-shot `bdrive sync --note`).
|
||||
a.Note = "claude-code session s-123"
|
||||
write(t, a.Folder, "wiki/plan.md", "v1")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
ops, err := b.Store.DeviceOps("deva")
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops))
|
||||
}
|
||||
if ops[0].Note != "claude-code session s-123" {
|
||||
t.Fatalf("note on peer = %q", ops[0].Note)
|
||||
}
|
||||
|
||||
// Persisted note: an empty Session.Note falls back to the store note, the
|
||||
// path the daemon takes after a one-shot sync persisted it.
|
||||
a.Note = ""
|
||||
if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, a.Folder, "wiki/plan.md", "v2")
|
||||
cycle(t, a)
|
||||
ops, _ = a.Store.DeviceOps("deva")
|
||||
if got := ops[len(ops)-1].Note; got != "claude-code session s-456" {
|
||||
t.Fatalf("persisted-note op = %q", got)
|
||||
}
|
||||
|
||||
// Deletes carry the note too.
|
||||
if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cycle(t, a)
|
||||
ops, _ = a.Store.DeviceOps("deva")
|
||||
last := ops[len(ops)-1]
|
||||
if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" {
|
||||
t.Fatalf("delete op = %+v", last)
|
||||
}
|
||||
|
||||
// An expired note stops applying: later edits stay unlabeled.
|
||||
if err := a.Store.SaveNote("stale", time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(t, a.Folder, "wiki/new.md", "x")
|
||||
cycle(t, a)
|
||||
ops, _ = a.Store.DeviceOps("deva")
|
||||
if got := ops[len(ops)-1].Note; got != "" {
|
||||
t.Fatalf("expired note leaked onto op: %q", got)
|
||||
}
|
||||
|
||||
// Clearing removes the note file entirely.
|
||||
if err := a.Store.SaveNote("", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.Store.LoadNote(); got != "" {
|
||||
t.Fatalf("cleared note = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ type Session struct {
|
||||
// history shows who changed what. Zero on offline/no-auth setups —
|
||||
// Device.Author remains the fallback identity.
|
||||
Account config.Settings
|
||||
// Note, when set, is stamped into every op this session commits — session
|
||||
// context like "claude-code session <id>". Empty means fall back to the
|
||||
// store's persisted session note (store.LoadNote), which lets a one-shot
|
||||
// `bdrive sync --note` leave context that the daemon's later scans also
|
||||
// stamp. Conflict-copy ops keep their own explanatory note.
|
||||
Note string
|
||||
Backend remote.Backend // nil = work offline
|
||||
// OnProgress, when set, is called during push with upload progress. It may
|
||||
// be invoked concurrently from upload workers, so it must be safe to call
|
||||
@@ -210,6 +216,10 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
||||
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64, filter *Filter) ([]journal.Op, error) {
|
||||
seen := make(map[string]bool, len(cache))
|
||||
var ops []journal.Op
|
||||
note := s.Note
|
||||
if note == "" {
|
||||
note = s.Store.LoadNote()
|
||||
}
|
||||
nextOp := func(kind, rel string) journal.Op {
|
||||
st.Lamport++
|
||||
seqBase++
|
||||
@@ -217,7 +227,7 @@ func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, s
|
||||
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
||||
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
||||
User: s.Account.Email, UserName: s.Account.Name,
|
||||
Kind: kind, Path: rel,
|
||||
Kind: kind, Path: rel, Note: note,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// HistoryEntry is one change as the history API reports it.
|
||||
type HistoryEntry struct {
|
||||
Time string `json:"time"`
|
||||
Kind string `json:"kind"` // put | delete
|
||||
Kind string `json:"kind"` // add | edit | delete
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
|
||||
@@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
journal.Sort(all)
|
||||
// A put is an "add" when the path didn't exist just before it (first
|
||||
// version, or first after a delete), an "edit" otherwise. Existence is
|
||||
// replayed over ALL ops in journal order, before any path/prefix filter,
|
||||
// so a filtered view classifies the same as the full feed.
|
||||
kinds := make([]string, len(all))
|
||||
exists := make(map[string]bool, len(all))
|
||||
for i, op := range all {
|
||||
switch {
|
||||
case op.Kind == journal.KindDelete:
|
||||
kinds[i] = "delete"
|
||||
exists[op.Path] = false
|
||||
case exists[op.Path]:
|
||||
kinds[i] = "edit"
|
||||
default:
|
||||
kinds[i] = "add"
|
||||
exists[op.Path] = true
|
||||
}
|
||||
}
|
||||
entries := make([]HistoryEntry, 0, n)
|
||||
for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first
|
||||
op := all[i]
|
||||
@@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
|
||||
}
|
||||
entries = append(entries, HistoryEntry{
|
||||
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind,
|
||||
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
|
||||
Path: op.Path, Size: op.Size, Blob: op.Blob,
|
||||
User: op.User, UserName: op.UserName, Author: op.Author,
|
||||
Device: dev, Note: op.Note,
|
||||
|
||||
@@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) {
|
||||
if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) {
|
||||
t.Fatalf("order wrong: %+v", out.Entries)
|
||||
}
|
||||
// puts are classified: first version = add, later versions = edit
|
||||
if oldest.Kind != "add" || newest.Kind != "edit" {
|
||||
t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind)
|
||||
}
|
||||
if newest.User != "alice@x.io" || newest.UserName != "Alice" {
|
||||
t.Fatalf("user = %+v", newest)
|
||||
}
|
||||
@@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) {
|
||||
if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" {
|
||||
t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0])
|
||||
}
|
||||
// the put that created other.md is an add, even in the filtered view
|
||||
if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" {
|
||||
t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1])
|
||||
}
|
||||
// a device the registry never saw falls back to the op's own info
|
||||
if out.Entries[0].Device.Name != "dev2" {
|
||||
t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device)
|
||||
|
||||
+205
-39
@@ -3,6 +3,7 @@
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let flatFiles = []; // [{path, name}] for wikilink resolution
|
||||
let dirIndex = new Map(); // dir path → tree node, for folder listings
|
||||
let currentPath = null;
|
||||
let expanded = new Set(); // dir paths that are open (folders start closed)
|
||||
let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project
|
||||
@@ -92,7 +93,7 @@ async function boot() {
|
||||
initUpload();
|
||||
await refreshTree();
|
||||
const { path } = parseRoute();
|
||||
if (path) openFile(path);
|
||||
if (path) openPath(path);
|
||||
}
|
||||
setInterval(refreshTree, 15000); // pick up synced changes
|
||||
}
|
||||
@@ -161,7 +162,7 @@ function selectProject(p, path) {
|
||||
initUpload();
|
||||
initHistory();
|
||||
updateShareButton();
|
||||
refreshTree().then(() => { if (path) openFile(path); });
|
||||
refreshTree().then(() => { if (path) openPath(path); });
|
||||
if (!path) pushURL("/" + p.id);
|
||||
}
|
||||
|
||||
@@ -676,6 +677,7 @@ async function refreshTree() {
|
||||
root = await getJSON(apiBase + "tree");
|
||||
} catch { return; } // keep the last good tree
|
||||
flatFiles = [];
|
||||
dirIndex = new Map();
|
||||
const kids = root.children || [];
|
||||
// First render of a project's tree: every folder starts closed, except a
|
||||
// lone root folder — opening it spares the user a single shut folder.
|
||||
@@ -688,6 +690,10 @@ async function refreshTree() {
|
||||
nav.innerHTML = "";
|
||||
nav.appendChild(renderChildren(kids));
|
||||
markActive();
|
||||
// A folder listing shows live tree data — keep it in step with the tree.
|
||||
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
|
||||
renderFolderListing(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren(children) {
|
||||
@@ -719,6 +725,7 @@ function renderNode(n) {
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
li.appendChild(row);
|
||||
if (n.dir) {
|
||||
dirIndex.set(n.path, n);
|
||||
if (serverConfig.mode === "hub") {
|
||||
const hist = document.createElement("span");
|
||||
hist.className = "dir-history";
|
||||
@@ -731,12 +738,21 @@ function renderNode(n) {
|
||||
const open = expanded.has(n.path);
|
||||
if (!open) li.classList.add("collapsed");
|
||||
row.setAttribute("aria-expanded", String(open));
|
||||
row.onclick = () => {
|
||||
const toggle = () => {
|
||||
li.classList.toggle("collapsed");
|
||||
const isCollapsed = li.classList.contains("collapsed");
|
||||
isCollapsed ? expanded.delete(n.path) : expanded.add(n.path);
|
||||
row.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
};
|
||||
// The chevron only folds; the row selects the folder (opens it in the
|
||||
// tree and lists its contents in the main pane). Clicking the folder
|
||||
// whose listing is already showing folds/unfolds it, like a plain tree;
|
||||
// from any other view (file, history) it brings the listing back.
|
||||
chev.onclick = (e) => { e.stopPropagation(); toggle(); };
|
||||
row.onclick = () => {
|
||||
if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; }
|
||||
openFolder(n.path);
|
||||
};
|
||||
} else {
|
||||
flatFiles.push({ path: n.path, name: n.name });
|
||||
row.onclick = () => openFile(n.path);
|
||||
@@ -780,6 +796,122 @@ function revealInTree(p) {
|
||||
if (row) row.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
/* ---- breadcrumb ----
|
||||
Every ancestor segment is a link to that folder's listing; the last
|
||||
segment is the current page. */
|
||||
function setCrumb(p) {
|
||||
const c = $("crumb");
|
||||
c.innerHTML = "";
|
||||
const parts = p.split("/");
|
||||
let acc = "";
|
||||
parts.forEach((seg, i) => {
|
||||
acc = acc ? acc + "/" + seg : seg;
|
||||
if (i) el(c, "span", "crumb-sep", "/");
|
||||
const last = i === parts.length - 1;
|
||||
const s = el(c, "span", last ? null : "crumb-seg", seg);
|
||||
if (!last) {
|
||||
const target = acc;
|
||||
s.title = target;
|
||||
s.onclick = () => { if (dirIndex.has(target)) openFolder(target); };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- folder pane ---- */
|
||||
|
||||
/* Open a path of either kind — a route or link doesn't know which it is
|
||||
until the tree has loaded. */
|
||||
function openPath(p) {
|
||||
if (dirIndex.has(p)) openFolder(p);
|
||||
else openFile(p);
|
||||
}
|
||||
|
||||
/* Selecting a folder: unfold it in the tree, highlight it, and list what's
|
||||
inside it in the main pane. */
|
||||
function openFolder(p) {
|
||||
if (!dirIndex.has(p)) return;
|
||||
currentPath = p;
|
||||
syncURL(p);
|
||||
expandTo(p);
|
||||
expanded.add(p); // the selected folder itself opens, not just its ancestors
|
||||
applyTreeExpansion();
|
||||
markActive();
|
||||
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
closeSidebarOnMobile();
|
||||
setCrumb(p);
|
||||
$("meta").textContent = "";
|
||||
$("download").hidden = true;
|
||||
$("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject);
|
||||
updateShareButton();
|
||||
renderFolderListing(p);
|
||||
}
|
||||
|
||||
function renderFolderListing(p) {
|
||||
const node = dirIndex.get(p);
|
||||
if (!node) return;
|
||||
const content = $("content");
|
||||
content.className = "view";
|
||||
content.innerHTML = "";
|
||||
const wrap = el(content, "div", "dirlist");
|
||||
const head = el(wrap, "h1", "dl-title");
|
||||
const hicon = el(head, "span", "dl-title-icon");
|
||||
hicon.innerHTML = svgIcon("folder");
|
||||
el(head, "span", null, node.name);
|
||||
const kids = (node.children || []).slice()
|
||||
.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
|
||||
const dirs = kids.filter((c) => c.dir).length;
|
||||
const files = kids.length - dirs;
|
||||
const counts = [];
|
||||
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
|
||||
if (files) counts.push(files + (files === 1 ? " file" : " files"));
|
||||
el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder");
|
||||
if (!kids.length) {
|
||||
el(wrap, "div", "dl-empty", "Nothing in this folder yet.");
|
||||
renderFolderHistory(wrap, p);
|
||||
return;
|
||||
}
|
||||
const list = el(wrap, "div", "dl-items");
|
||||
for (const c of kids) {
|
||||
const row = el(list, "div", "dl-row");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.title = c.path;
|
||||
const icon = el(row, "span", "ticon");
|
||||
icon.innerHTML = svgIcon(c.dir ? "folder" : "doc");
|
||||
el(row, "span", "dl-name", c.name);
|
||||
let meta = "";
|
||||
if (c.dir) {
|
||||
const n = (c.children || []).length;
|
||||
meta = n + (n === 1 ? " item" : " items");
|
||||
} else {
|
||||
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
|
||||
.filter(Boolean).join(" · ");
|
||||
}
|
||||
el(row, "span", "dl-meta", meta);
|
||||
row.onclick = () => openPath(c.path);
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
}
|
||||
renderFolderHistory(wrap, p);
|
||||
}
|
||||
|
||||
/* The folder's change feed, straight from the journals: files added, edited,
|
||||
and deleted anywhere under it, newest first. Hub-only — a plain-folder
|
||||
viewer has no journals to read. */
|
||||
function renderFolderHistory(wrap, p) {
|
||||
if (!(serverConfig.mode === "hub" && currentProject)) return;
|
||||
const sec = el(wrap, "div", "dl-history");
|
||||
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
|
||||
const entries = out.entries || [];
|
||||
if (!entries.length) return sec.remove();
|
||||
el(sec, "h3", "dl-h3", "Recent changes");
|
||||
const list = el(sec, "div", "history dl-hlist");
|
||||
for (const e of entries) list.appendChild(historyEntryRow(e));
|
||||
const more = el(sec, "button", "ai-btn dl-more", "Full history");
|
||||
more.onclick = () => showHistory({ prefix: p + "/" });
|
||||
}).catch(() => sec.remove());
|
||||
}
|
||||
|
||||
/* ---- file pane ---- */
|
||||
async function openFile(p) {
|
||||
currentPath = p;
|
||||
@@ -787,7 +919,7 @@ async function openFile(p) {
|
||||
markActive();
|
||||
revealInTree(p);
|
||||
closeSidebarOnMobile();
|
||||
$("crumb").textContent = p.split("/").join(" / ");
|
||||
setCrumb(p);
|
||||
updateShareButton();
|
||||
const dl = $("download");
|
||||
dl.href = apiBase + "download?path=" + encodeURIComponent(p);
|
||||
@@ -916,7 +1048,8 @@ function join(dir, rel) {
|
||||
(rendered, sandboxed), no account needed. Always the latest content. */
|
||||
function updateShareButton() {
|
||||
const btn = $("share-btn");
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath);
|
||||
// Shares are per-file; a selected folder has nothing to mint.
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath));
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
const r = await fetch(apiBase + "shares", {
|
||||
@@ -941,7 +1074,11 @@ function updateShareButton() {
|
||||
function initHistory() {
|
||||
const btn = $("history-btn");
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull());
|
||||
btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" });
|
||||
btn.onclick = () => {
|
||||
if (!currentPath) return showHistory({ prefix: "" });
|
||||
if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" });
|
||||
showHistory({ path: currentPath });
|
||||
};
|
||||
}
|
||||
|
||||
function currentProjectOrNull() {
|
||||
@@ -972,39 +1109,63 @@ async function showHistory(q) {
|
||||
if (!out.entries || out.entries.length === 0) {
|
||||
wrap.innerHTML = `<div class="empty">No history yet.</div>`;
|
||||
}
|
||||
for (const e of out.entries || []) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hentry " + e.kind;
|
||||
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const when = new Date(e.time).toLocaleString();
|
||||
row.innerHTML =
|
||||
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htime"></span></div>` +
|
||||
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
|
||||
row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot");
|
||||
row.querySelector(".hpath").textContent = e.path;
|
||||
row.querySelector(".htime").textContent = when;
|
||||
row.querySelector(".hwho").textContent = who;
|
||||
row.querySelector(".hdev").textContent = dev;
|
||||
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
|
||||
if (e.kind !== "delete" && e.blob) {
|
||||
const view = document.createElement("a");
|
||||
view.textContent = "view";
|
||||
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
|
||||
view.target = "_blank";
|
||||
const dl = document.createElement("a");
|
||||
dl.textContent = "download";
|
||||
dl.href = view.href + "&download=1";
|
||||
dl.setAttribute("download", e.path.split("/").pop());
|
||||
row.querySelector(".hact").append(view, " ", dl);
|
||||
}
|
||||
const p = e.path;
|
||||
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e));
|
||||
content.appendChild(wrap);
|
||||
}
|
||||
|
||||
/* One change as a row: what happened (added / edited / deleted), to which
|
||||
file, by whom, from where — with view/download of that exact version. */
|
||||
const KIND_ICON = { add: "plus", edit: "edit", delete: "x" };
|
||||
const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" };
|
||||
function historyEntryRow(e) {
|
||||
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
|
||||
const row = document.createElement("div");
|
||||
row.className = "hentry " + kind;
|
||||
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const when = new Date(e.time).toLocaleString();
|
||||
row.innerHTML =
|
||||
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htag"></span><span class="htime"></span></div>` +
|
||||
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
|
||||
row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot");
|
||||
row.querySelector(".hpath").textContent = e.path;
|
||||
row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind;
|
||||
row.querySelector(".htime").textContent = when;
|
||||
row.querySelector(".hwho").textContent = who;
|
||||
row.querySelector(".hdev").textContent = dev;
|
||||
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
|
||||
if (kind !== "delete" && e.blob) {
|
||||
const view = document.createElement("a");
|
||||
view.textContent = "view";
|
||||
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
|
||||
view.target = "_blank";
|
||||
const dl = document.createElement("a");
|
||||
dl.textContent = "download";
|
||||
dl.href = view.href + "&download=1";
|
||||
dl.setAttribute("download", e.path.split("/").pop());
|
||||
row.querySelector(".hact").append(view, " ", dl);
|
||||
}
|
||||
if (e.note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "hnote";
|
||||
// Linkify http(s) URLs (e.g. a Claude session link); everything else
|
||||
// stays plain text — notes are user/agent input, never markup.
|
||||
for (const tok of e.note.split(/(https?:\/\/\S+)/)) {
|
||||
if (/^https?:\/\//.test(tok)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener";
|
||||
note.appendChild(a);
|
||||
} else if (tok) {
|
||||
note.append(tok);
|
||||
}
|
||||
}
|
||||
row.appendChild(note);
|
||||
}
|
||||
const p = e.path;
|
||||
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
|
||||
return row;
|
||||
}
|
||||
|
||||
function humanSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
@@ -1029,8 +1190,10 @@ function initUpload() {
|
||||
const file = input.files[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
const dir = currentPath && currentPath.includes("/")
|
||||
? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
// A selected folder receives the upload; a selected file means "next to it".
|
||||
const dir = !currentPath ? ""
|
||||
: dirIndex.has(currentPath) ? currentPath
|
||||
: currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
const dest = dir ? dir + "/" + file.name : file.name;
|
||||
const status = $("meta");
|
||||
try {
|
||||
@@ -1204,6 +1367,9 @@ function paletteCandidates() {
|
||||
if (serverConfig.auth && serverConfig.auth.enabled) {
|
||||
add("power", "Sign out", "action", () => { location.href = "/auth/logout"; });
|
||||
}
|
||||
for (const d of dirIndex.keys()) {
|
||||
add("folder", d, "folder", () => openFolder(d));
|
||||
}
|
||||
for (const f of flatFiles) {
|
||||
add("doc", f.path, "file", () => openFile(f.path));
|
||||
}
|
||||
@@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => {
|
||||
const proj = projects.find((x) => x.id === project);
|
||||
if (proj) { selectProject(proj, path || null); return; }
|
||||
}
|
||||
if (path && path !== currentPath) openFile(path);
|
||||
if (path && path !== currentPath) openPath(path);
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
|
||||
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
|
||||
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
|
||||
|
||||
@@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; }
|
||||
.icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; }
|
||||
.icon-btn:hover { color: var(--text); background: var(--hover); }
|
||||
#crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; }
|
||||
#crumb .crumb-seg:hover { color: var(--accent-bright); }
|
||||
#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; }
|
||||
#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.btn {
|
||||
@@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; }
|
||||
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; }
|
||||
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
|
||||
|
||||
/* ---- folder listing ---- */
|
||||
.dirlist { max-width: 704px; margin: 0 auto; }
|
||||
.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
|
||||
.dl-title-icon { display: flex; color: var(--accent); }
|
||||
.dl-title-icon .ico { width: 20px; height: 20px; }
|
||||
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
|
||||
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
|
||||
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.dl-row:last-child { border-bottom: none; }
|
||||
.dl-row:hover { background: var(--hover); }
|
||||
.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); }
|
||||
.dl-row .ticon .ico { width: 16px; height: 16px; }
|
||||
.dl-row:hover .ticon { color: var(--text-faint); }
|
||||
.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); }
|
||||
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; }
|
||||
.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; }
|
||||
.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; }
|
||||
.dl-hlist .hentry:last-child { border-bottom: none; }
|
||||
.dl-more { margin-top: 10px; }
|
||||
|
||||
/* ---- history ---- */
|
||||
.history { max-width: 860px; }
|
||||
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
|
||||
@@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; }
|
||||
.hline { display: flex; gap: 10px; align-items: center; }
|
||||
.hkind { display: inline-flex; color: var(--add); }
|
||||
.hkind .ico { width: 13px; height: 13px; }
|
||||
.hentry.edit .hkind { color: var(--accent); }
|
||||
.hentry.delete .hkind { color: var(--del); }
|
||||
.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
|
||||
.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); }
|
||||
.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); }
|
||||
.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); }
|
||||
.hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; }
|
||||
.hpath:hover { color: var(--accent-bright); }
|
||||
.htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
@@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; }
|
||||
.hact { margin-left: auto; }
|
||||
.hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; }
|
||||
.hact a:hover { text-decoration: underline; }
|
||||
.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hnote::before { content: "› "; color: var(--text-ghost); }
|
||||
.hnote a { color: var(--accent-bright); text-decoration: none; }
|
||||
.hnote a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- command palette ---- */
|
||||
#palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; }
|
||||
@@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; }
|
||||
#invite-btn { min-height: 40px; padding: 0 14px; }
|
||||
#org-name { min-height: 44px; }
|
||||
.nav-add { min-width: 44px; min-height: 44px; }
|
||||
.markdown, .admin, .onboard, .history { max-width: 100%; }
|
||||
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
|
||||
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
|
||||
.ob-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
+10
-1
@@ -52,7 +52,16 @@ Follow these steps:
|
||||
bdrive init --name <project> --shared wiki # in a repo: only ./wiki syncs
|
||||
```
|
||||
|
||||
5. **Verify**: run `bdrive status <folder>` and confirm the daemon is
|
||||
5. **Register agent sync hooks**: run `bdrive hooks install <folder>`. It
|
||||
detects the agent platforms in use (Claude Code, Codex, Gemini CLI,
|
||||
Hermes — by their config dirs in the project or home) and idempotently
|
||||
merges beardrive's sync hooks into each platform's own hook config, so
|
||||
files pull at every turn start, push after edits, and every change is
|
||||
stamped with the agent session that made it. Tell the user which
|
||||
platforms got hooks; if Codex is among them, mention they must run
|
||||
`/hooks` inside Codex once to trust the project's `.codex` layer.
|
||||
|
||||
6. **Verify**: run `bdrive status <folder>` and confirm the daemon is
|
||||
running and pending is 0. Summarize: project name/id, what syncs, and
|
||||
that edits propagate to every team member within seconds. Offer a
|
||||
consent-gated CLAUDE.md note and tell the user how teammates connect
|
||||
|
||||
+19
-40
@@ -61,49 +61,28 @@ every change is tracked (who, when, from which device).
|
||||
the URL.
|
||||
```
|
||||
|
||||
## 5. Register project-level sync hooks (ask first)
|
||||
## 5. Register agent sync hooks
|
||||
|
||||
Ask: "Want me to register sync hooks in `.claude/settings.json` so files
|
||||
sync automatically during Claude sessions — for every teammate, plugin or
|
||||
not?" If yes, merge this into the project's `.claude/settings.json`
|
||||
(create it if missing; preserve existing hooks — append to the arrays,
|
||||
never overwrite them):
|
||||
Run `bdrive hooks install` in the project. It detects the agent platforms
|
||||
in use — Claude Code (`.claude/`), Codex (`.codex/`), Gemini CLI
|
||||
(`.gemini/`), Hermes (`~/.hermes/`) — and idempotently merges beardrive's
|
||||
sync hooks into each platform's own hook config, preserving any hooks
|
||||
already there. Project-level files (`.claude/settings.json`,
|
||||
`.codex/hooks.json`, `.gemini/settings.json`) ride the repo, so every
|
||||
teammate gets them — plugin or not, whatever agent they use; Hermes hooks
|
||||
are per-user (`~/.hermes/config.yaml`).
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
|
||||
"timeout": 30,
|
||||
"statusMessage": "beardrive: pulling latest files"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'cd \"${CLAUDE_PROJECT_DIR:-.}\" && [ -d .bdrive ] && command -v bdrive >/dev/null && bdrive sync . >/dev/null 2>&1 || true'",
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
The registered hooks pull before every turn (the agent always reads the
|
||||
team's latest files), push right after edits (artifacts land on the server
|
||||
seconds after they're created — daemon or no daemon), and stamp every
|
||||
change with the agent session that made it (`bdrive sync --note "<agent>
|
||||
session <id>"` — visible in `bdrive log` and the hub's history views).
|
||||
They are fast no-ops in folders without `.bdrive/`.
|
||||
|
||||
The pull at prompt-submit means Claude always reads the team's latest
|
||||
files; the async push after each Write/Edit means artifacts land on the
|
||||
server seconds after Claude creates them — daemon or no daemon. Both are
|
||||
fast no-ops in folders without `.bdrive/`.
|
||||
Tell the user which platforms got hooks (`bdrive hooks` shows the status
|
||||
table). If Codex is among them, mention they must run `/hooks` inside
|
||||
Codex once to trust the project's `.codex` layer. To register a platform
|
||||
that wasn't detected: `bdrive hooks install --agent claude,codex,gemini,hermes`.
|
||||
|
||||
## 6. Verify and summarize
|
||||
|
||||
|
||||
@@ -4,7 +4,20 @@
|
||||
#
|
||||
# Runs blocking on UserPromptSubmit (fresh files before Claude reads them)
|
||||
# and async on Stop (push edits out without delaying the turn).
|
||||
#
|
||||
# Hooks receive JSON on stdin with a session_id; it is passed as the sync
|
||||
# note so every change journaled during this session — including ones the
|
||||
# background daemon commits — is stamped with the Claude Code session that
|
||||
# made it, and shows up in `bdrive log` and the hub's history views.
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
[ -d .bdrive ] || exit 0
|
||||
command -v bdrive >/dev/null 2>&1 || exit 0
|
||||
bdrive sync . >/dev/null 2>&1 || true
|
||||
sid=""
|
||||
if [ ! -t 0 ]; then
|
||||
sid=$(head -c 8192 | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
fi
|
||||
if [ -n "$sid" ]; then
|
||||
bdrive sync . --note "claude-code session $sid" >/dev/null 2>&1 || true
|
||||
else
|
||||
bdrive sync . >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
@@ -17,13 +17,14 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
||||
| Run the daemon in the foreground | `bdrive init -f` |
|
||||
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` |
|
||||
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
|
||||
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
|
||||
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
|
||||
| This device's identity | `bdrive whoami` |
|
||||
| Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless); `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
|
||||
| Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. |
|
||||
| Share a synced file publicly by URL | `bdrive share <file>` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke <token-or-url>` to manage. Put generated reports in the shared folder, sync, then share. |
|
||||
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers project-level hooks (blocking pull at prompt-submit, async push after Write/Edit) in `.claude/settings.json` |
|
||||
| Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers agent sync hooks via `bdrive hooks install` (pull at turn start, push after edits, session-note stamping — for every detected platform, not just Claude) |
|
||||
| Per-file / folder change history in the web UI | History button (file versions or project feed) and per-folder ⌚ — each entry: account, time, device (name/OS/IP), view/download of that exact version. API: `GET /api/p/<id>/history?path=\|prefix=`, `GET /api/p/<id>/blob?sha=` |
|
||||
| Web server: viewer + multi-project sync hub (read-only unless `--upload`) | `bdrive web [<folder> \| <storage-root-url>]` (serves cwd by default, `--addr :4173`; `-c config.json` reads remote/addr/upload/projects_db/database/auth settings from a file, explicit flags win; a storage root URL makes it a hub hosting many projects at `<root>/<project-id>/`, registry in `--projects-db` file, default `$BDRIVE_HOME/projects.json`; `--upload` lets browsers add files, client devices push, and projects be created — direct to storage via expiring presigned URLs on S3/GCS, relayed through the server for `file://`; `--upload-ttl 15m`; clients never see the remote URL or credentials; hub projects are walled by org membership — invite teammates from the web UI; the viewer has a ⌘K palette for fuzzy file search, project switching, and quick actions) |
|
||||
|
||||
@@ -114,6 +115,35 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished
|
||||
- Verify credentials and the remote end-to-end.
|
||||
- Sync once even when the daemon is stopped.
|
||||
|
||||
**Session-linked notes**: `bdrive sync --note "<text>"` stamps the text onto
|
||||
every change this cycle commits — and persists it (in the mount's volume
|
||||
store, never synced) so changes the background daemon commits over the next
|
||||
`--note-ttl` (default 30m) carry it too. Notes appear in `bdrive log` (as
|
||||
`[note]`) and under each entry in the hub's history views. The plugin's sync
|
||||
hooks pass `--note "claude-code session <session-id>"` automatically, so
|
||||
every change made during a Claude Code session is traceable to the session
|
||||
that made it. An explicit empty `--note ""` clears the persisted note;
|
||||
conflict-copy ops keep their own `conflict copy of <path>` note.
|
||||
|
||||
### Agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes)
|
||||
|
||||
`bdrive hooks install [<folder>]` registers turn-boundary sync for every
|
||||
agent platform it detects (by config dir, in the project or home):
|
||||
|
||||
| Platform | Config it writes | Pull / push events |
|
||||
|---|---|---|
|
||||
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) |
|
||||
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) — user must `/hooks`-trust the layer once |
|
||||
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) |
|
||||
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) |
|
||||
|
||||
Every platform pipes hook JSON with a `session_id`, so one hook command
|
||||
serves all four: it syncs the project (fast no-op outside bdrive folders)
|
||||
and stamps changes with `<agent> session <id>`. Merging is idempotent and
|
||||
preserves existing hooks; `--agent claude,codex,gemini,hermes` overrides
|
||||
detection; bare `bdrive hooks` prints the detection/registration table.
|
||||
Project-level configs ride the repo, so hooks reach the whole team.
|
||||
|
||||
### Examples to walk a user through
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user