feat: web viewer, project-local config, selective sync, Claude Code plugin

- sfs-web (cmd/sfs-web, internal/webapp): read-only Obsidian-style web UI
  serving a local folder (default) or an sfs remote; markdown rendering
  with [[wikilinks]], task lists and tables, file downloads with ETags,
  per-file provenance from the journals; added to goreleaser builds
- .sfs project file (internal/config): per-folder volume/remote/include
  settings that travel with the folder, win over the global registry, and
  never sync; daemon picks up edits live
- .sfsignore + include lists (internal/syncer): gitignore-style selective
  sync with ! re-includes, applied symmetrically in scan and materialize;
  newly ignored files stop syncing without being deleted anywhere
- Claude Code plugin (plugin/, .claude-plugin/): sfs skill, /sfs:mount and
  /sfs:status commands, turn-boundary sync hooks (blocking pull on prompt,
  async push on stop); installable via the repo's marketplace manifest
- CLAUDE.md and .claude project settings for Claude Code development

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
This commit is contained in:
Snow Lee
2026-07-07 14:05:36 -07:00
co-authored by Claude Fable 5
parent d22fe71d1c
commit 2d29ff8aac
33 changed files with 2016 additions and 56 deletions
+117
View File
@@ -0,0 +1,117 @@
// sfs-web serves a read-only website for a folder or an sfs remote: browse
// folders and files, read rendered markdown (Obsidian-style, including
// [[wikilinks]]), and download any file.
//
// Two sources:
//
// - a local folder, served straight from disk (the default — on an sfs
// mount the daemon keeps it fresh, so this is the simplest deployment);
// - an sfs remote, read directly from the object store with per-file
// provenance from the journals — no mount, daemon, or local state needed.
//
// Examples:
//
// sfs-web # serve the current directory
// sfs-web ./notes # serve a folder
// sfs-web s3://bucket/prefix # serve an sfs remote
// sfs-web --remote gs://bucket/prefix --addr :8080
package main
import (
"context"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/webapp"
)
func main() {
remoteURL := flag.String("remote", "", "sfs remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
dir := flag.String("dir", "", "local folder to serve (default: current directory)")
addr := flag.String("addr", ":4173", "address to listen on")
volume := flag.String("volume", "", "volume display name (default: folder or remote basename)")
refresh := flag.Duration("refresh", 10*time.Second, "how long to cache the file listing")
flag.Parse()
// Positional argument: a URL selects remote mode, anything else is a
// folder. With nothing specified at all, serve the current directory.
if *remoteURL == "" && *dir == "" && flag.NArg() > 0 {
if arg := flag.Arg(0); strings.Contains(arg, "://") {
*remoteURL = arg
} else {
*dir = arg
}
}
if *remoteURL != "" && *dir != "" {
fmt.Fprintln(os.Stderr, "usage: sfs-web [folder | remote-url] [--addr :4173] (--remote and --dir are mutually exclusive)")
os.Exit(2)
}
if *remoteURL == "" && *dir == "" {
*dir = "."
}
ctx := context.Background()
var src webapp.Source
var display, name string
if *dir != "" {
abs, err := filepath.Abs(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", abs)
os.Exit(1)
}
src = &webapp.DirSource{Root: abs}
display, name = abs, filepath.Base(abs)
} else {
be, err := remote.Open(ctx, *remoteURL)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
defer be.Close()
src = &webapp.RemoteSource{Backend: be}
display, name = *remoteURL, volumeName(*remoteURL)
}
if *volume != "" {
name = *volume
}
srv := &webapp.Server{
Source: src,
Remote: display,
Volume: name,
Refresh: *refresh,
}
shown := *addr
if strings.HasPrefix(shown, ":") {
shown = "localhost" + shown
}
fmt.Printf("sfs-web serving %s\n volume: %s\n url: http://%s\n", display, name, shown)
if err := http.ListenAndServe(*addr, srv.Handler()); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func volumeName(remoteURL string) string {
if u, err := url.Parse(remoteURL); err == nil {
if base := path.Base(strings.Trim(u.Path, "/")); base != "" && base != "." {
return base
}
if u.Host != "" {
return u.Host
}
}
return "sfs"
}
+16 -5
View File
@@ -75,6 +75,9 @@ func statusCmd() *cobra.Command {
fmt.Println()
}
first = false
if eff, _, found, err := config.EffectiveMount(folder); err == nil && found {
mi = eff // .sfs project file wins over the registry
}
fmt.Printf("%s\n", folder)
fmt.Printf(" volume: %s\n", mi.Volume)
if mi.Remote != "" {
@@ -203,19 +206,27 @@ func remoteCmd() *cobra.Command {
if err != nil || (u.Scheme != "s3" && u.Scheme != "gs" && u.Scheme != "file") {
return fmt.Errorf("invalid remote %q (want s3://bucket/prefix, gs://bucket/prefix, or file:///path)", raw)
}
mi, err := mustMount(folder)
if err != nil {
return err
}
mi.Remote = raw
mounts, err := config.LoadMounts()
if err != nil {
return err
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
}
mi.Remote = raw
mounts[folder] = mi
if err := config.SaveMounts(mounts); err != nil {
return err
}
proj, _, err := config.LoadProject(folder)
if err != nil {
return err
}
proj.Volume, proj.Remote = mi.Volume, raw
if err := config.SaveProject(folder, proj); err != nil {
return err
}
fmt.Printf("remote of %s set to %s\n", folder, raw)
fmt.Println("run `sfs sync` to sync now (a running daemon picks it up automatically)")
return nil
+9 -4
View File
@@ -20,15 +20,20 @@ func absFolder(args []string) (string, error) {
return filepath.Abs(arg)
}
// mustMount resolves a folder's settings: the .sfs project file wins over
// the global registry, so a folder that carries its own .sfs works even
// before it is registered on this device.
func mustMount(folder string) (config.MountInfo, error) {
mounts, err := config.LoadMounts()
mi, _, found, err := config.EffectiveMount(folder)
if err != nil {
return config.MountInfo{}, err
return mi, err
}
mi, ok := mounts[folder]
if !ok {
if !found {
return mi, fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
}
if mi.Volume == "" {
mi.Volume = filepath.Base(folder)
}
return mi, nil
}
+34 -13
View File
@@ -41,29 +41,48 @@ daemon keeps the folder in sync until "sfs umnt".`,
return err
}
// Settings resolution: flags win, then the folder's .sfs file,
// then the global registry. The result is written back to both,
// so the project file travels with the folder and the registry
// knows which mounts are active on this device.
mounts, err := config.LoadMounts()
if err != nil {
return err
}
mi, exists := mounts[folder]
if exists {
if volume != "" && volume != mi.Volume {
return fmt.Errorf("%s is already mounted as volume %q", folder, mi.Volume)
}
} else {
v := volume
if v == "" {
v = filepath.Base(folder)
}
mi = config.MountInfo{Volume: v}
reg, registered := mounts[folder]
proj, _, err := config.LoadProject(folder)
if err != nil {
return err
}
if remoteURL != "" {
mi.Remote = remoteURL
effVolume := proj.Volume
if effVolume == "" && registered {
effVolume = reg.Volume
}
if volume != "" && effVolume != "" && volume != effVolume {
return fmt.Errorf("%s is already mounted as volume %q", folder, effVolume)
}
if volume != "" {
effVolume = volume
}
if effVolume == "" {
effVolume = filepath.Base(folder)
}
effRemote := remoteURL
if effRemote == "" {
effRemote = proj.Remote
}
if effRemote == "" && registered {
effRemote = reg.Remote
}
mi := config.MountInfo{Volume: effVolume, Remote: effRemote}
mounts[folder] = mi
if err := config.SaveMounts(mounts); err != nil {
return err
}
proj.Volume, proj.Remote = effVolume, effRemote
if err := config.SaveProject(folder, proj); err != nil {
return err
}
vdir, err := config.VolumeDir(mi.Volume)
if err != nil {
return err
@@ -96,6 +115,8 @@ daemon keeps the folder in sync until "sfs umnt".`,
fmt.Printf(" remote: (none — local only; set one with `sfs remote set %s <url>`)\n", folder)
}
fmt.Printf(" device: %s (%s) as %s\n", dev.Name, dev.ID, dev.Author)
fmt.Printf(" config: %s (volume/remote/include; add a .sfsignore next to it to exclude paths)\n",
filepath.Join(folder, config.ProjectFile))
printCycle(res)
if foreground {