diff --git a/README.md b/README.md index 0dcc592..36e75cf 100644 --- a/README.md +++ b/README.md @@ -76,51 +76,48 @@ tap --help Create your first keg and start taking notes in under a minute. -**1. Set up configuration** +**1. Initialize a keg** ```bash -tap repo config edit +tap init --keg personal --user ``` -Opens your editor with the user config file (`~/.config/tapper/config.yaml`). -Set `fallbackKeg` to `personal` (or your preferred alias) and configure -`kegSearchPaths` so tapper knows where to find your kegs. Save and close. +Creates a keg under your first `kegSearchPaths` entry — or under the +platform user-data directory if no `kegSearchPaths` is configured — and +registers the alias in your user config. -**2. Initialize a keg** +> Tip: run `tap repo config edit` first if you want to set `fallbackKeg` +> (so later commands don't need `--keg`) or customize `kegSearchPaths`. +> Without configuration, tapper picks sensible platform defaults. -```bash -tap repo init --keg personal -``` - -Creates a keg under your first `kegSearchPaths` entry and registers the alias. - -**3. Create a node** +**2. Create a node** ```bash -tap create +tap create --keg personal ``` Opens your editor with a frontmatter template. Write your note, save, and -close. Since `fallbackKeg` is set, no `--keg` flag needed. +close. Set `fallbackKeg` in user config to skip the `--keg` flag on later +commands. -**4. View and edit a node** +**3. View and edit a node** ```bash -tap cat 1 +tap cat 1 --keg personal ``` On a terminal this opens the node in your editor for viewing and editing. -**5. List all nodes** +**4. List all nodes** ```bash -tap list +tap list --keg personal ``` -**6. Search** +**5. Search** ```bash -tap grep "first" +tap grep "first" --keg personal ``` That's it — you have a working knowledge base. See [More Examples](#more-examples) @@ -183,7 +180,7 @@ tap --path ~/Documents/kegs/pub snapshot history 12 Initialize a project-local keg: ```bash -tap repo init --keg tapper --project +tap init --keg tapper --project ``` Create and inspect node history: diff --git a/docs/README.md b/docs/README.md index 867554c..91579be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,7 +58,6 @@ If you are unsure where to start, read [Configuration Overview](configuration/RE ### Keg operations -- `tap dir [NODE_ID]` — print keg or node directory path - `tap index rebuild` — rebuild keg indices - `tap info` — show keg diagnostics - `tap config` — show active keg config @@ -91,7 +90,7 @@ it. ### Repository management -- `tap repo init [--keg ALIAS]` — initialize a keg with repo config +- `tap init [--keg ALIAS]` — initialize a keg target - `tap repo rm ALIAS` — remove a keg alias - `tap repo list` — list configured keg aliases - `tap repo config` — show merged repo config diff --git a/docs/architecture/testing-architecture.md b/docs/architecture/testing-architecture.md index d5248b5..4347be5 100644 --- a/docs/architecture/testing-architecture.md +++ b/docs/architecture/testing-architecture.md @@ -33,7 +33,7 @@ sandbox runtime, which acts like an in-memory workflow pipeline. Example sequence: -1. `tap repo init ...` +1. `tap init ...` 2. `tap create ...` 3. `tap cat ...` diff --git a/docs/keg-structure/domain-separation-and-migration.md b/docs/keg-structure/domain-separation-and-migration.md index f0e4daf..a9cdf16 100644 --- a/docs/keg-structure/domain-separation-and-migration.md +++ b/docs/keg-structure/domain-separation-and-migration.md @@ -34,7 +34,7 @@ interaction. Create a user-level destination keg target: ```bash -tap repo init --keg domain-x --user +tap init --keg domain-x --user ``` Inspect and edit new keg config: diff --git a/docs/keg-structure/example-structures.md b/docs/keg-structure/example-structures.md index f066039..e5f3597 100644 --- a/docs/keg-structure/example-structures.md +++ b/docs/keg-structure/example-structures.md @@ -81,7 +81,7 @@ graph with project-scoped defaults. ### Bootstrap Commands ```bash -tap repo init --keg tapper --project +tap init --keg tapper --project tap repo config --project tap config --project ``` diff --git a/pkg/cli/cmd_cat_test.go b/pkg/cli/cmd_cat_test.go index ebe807e..c098f8e 100644 --- a/pkg/cli/cmd_cat_test.go +++ b/pkg/cli/cmd_cat_test.go @@ -276,7 +276,7 @@ func TestCatCommand_IntegrationWithInit(t *testing.T) { // First, initialize a user keg initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--user", "--keg", "newstudy", "--creator", "test-user", @@ -307,7 +307,7 @@ func TestCatCommand_UserKeg(t *testing.T) { // First, initialize a user keg initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--user", "--keg", "public", "--creator", "test-user", diff --git a/pkg/cli/cmd_dir.go b/pkg/cli/cmd_dir.go deleted file mode 100644 index 023b0e2..0000000 --- a/pkg/cli/cmd_dir.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/spf13/cobra" -) - -func NewPwdCmd(deps *Deps) *cobra.Command { - var opts tapper.DirOptions - - cmd := &cobra.Command{ - Use: "dir [NODE_ID]", - Short: "print keg directory or node directory path", - Long: `Print a filesystem path for the resolved keg. - -With no NODE_ID, prints the keg root directory. -With NODE_ID, prints the node directory (/) for local file-backed kegs.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 1 { - opts.NodeID = args[0] - } - applyKegTargetProfile(deps, &opts.KegTargetOptions) - dir, err := deps.Tap.Dir(cmd.Context(), opts) - if err != nil { - return err - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), dir) - return err - }, - } - return cmd -} diff --git a/pkg/cli/cmd_dir_test.go b/pkg/cli/cmd_dir_test.go deleted file mode 100644 index 27b2071..0000000 --- a/pkg/cli/cmd_dir_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package cli_test - -import ( - "strings" - "testing" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/stretchr/testify/require" -) - -func TestDirCommand_ExpandsUserHomePath(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - - h := NewProcess(t, false, "dir") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "dir command should succeed") - - got := strings.TrimSpace(string(res.Stdout)) - require.NotEmpty(t, got) - require.NotContains(t, got, "~", "dir output should be a shell-usable absolute path") - require.Equal(t, "/home/testuser/kegs/personal", got) -} - -func TestDirCommand_ExpandsExplicitAliasPath(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - - h := NewProcess(t, false, "dir", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "dir command should succeed for explicit alias") - - got := strings.TrimSpace(string(res.Stdout)) - require.NotEmpty(t, got) - require.NotContains(t, got, "~", "dir output should be a shell-usable absolute path") - require.Equal(t, "/home/testuser/kegs/example", got) -} - -func TestDirCommand_NodeDirectory(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - createRes := NewProcess(t, false, "create", "--title", "ForDir").Run(sb.Context(), sb.Runtime()) - require.NoError(t, createRes.Err) - - h := NewProcess(t, false, "dir", "1", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "dir NODE_ID should succeed for existing node") - - got := strings.TrimSpace(string(res.Stdout)) - require.Equal(t, "/home/testuser/kegs/example/1", got) -} - -func TestDirCommand_NodeDirectoryErrors(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - res := NewProcess(t, false, "dir", "bad-id", "--keg", "example").Run(sb.Context(), sb.Runtime()) - require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "invalid node ID") - - res = NewProcess(t, false, "dir", "4242", "--keg", "example").Run(sb.Context(), sb.Runtime()) - require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "node 4242 not found") -} diff --git a/pkg/cli/cmd_graph_test.go b/pkg/cli/cmd_graph_test.go index c74493e..85ba15b 100644 --- a/pkg/cli/cmd_graph_test.go +++ b/pkg/cli/cmd_graph_test.go @@ -122,7 +122,7 @@ func TestKegGraphCommand_WorksOnProjectKeg(t *testing.T) { sb.Setwd("~") initRes := NewProcess(t, false, - "repo", "init", "--project", "--cwd", "--keg", "project", "--creator", "test-user", + "init", "--project", "--cwd", "--keg", "project", "--creator", "test-user", ).Run(sb.Context(), sb.Runtime()) require.NoError(t, initRes.Err) diff --git a/pkg/cli/cmd_index_test.go b/pkg/cli/cmd_index_test.go index 600c319..d8cbcca 100644 --- a/pkg/cli/cmd_index_test.go +++ b/pkg/cli/cmd_index_test.go @@ -240,7 +240,7 @@ func TestIndexRebuildCommand_IntegrationWithInit(t *testing.T) { sb := NewSandbox(innerT, opts...) initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--user", "--keg", "newstudy", "--creator", "test-user", diff --git a/pkg/cli/cmd_info_test.go b/pkg/cli/cmd_info_test.go index 1df9010..0c27bfd 100644 --- a/pkg/cli/cmd_info_test.go +++ b/pkg/cli/cmd_info_test.go @@ -66,7 +66,7 @@ func TestConfigCommand_IntegrationWithInit_KegConfig(t *testing.T) { // First, initialize a user keg initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--user", "--keg", "newstudy", "--creator", "test-user", diff --git a/pkg/cli/cmd_init.go b/pkg/cli/cmd_init.go new file mode 100644 index 0000000..407e345 --- /dev/null +++ b/pkg/cli/cmd_init.go @@ -0,0 +1,221 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/spf13/cobra" +) + +// NewInitCmd returns the `tap init` cobra command. +// +// Usage examples: +// +// tap init --keg blog +// tap init --project +// tap init --keg blog --cwd +// tap init --keg blog --hub knut --namespace me +// tap init --keg blog --path ./kegs/blog --title "Blog" --creator "me" +func NewInitCmd(deps *Deps) *cobra.Command { + initOpts := tapper.InitOptions{} + + cmd := &cobra.Command{ + Use: "init", + Short: "create a new keg target", + Args: cobra.NoArgs, + Long: strings.TrimSpace(` +Create a keg target and initialize it in one of three destinations: + +1. user (default) + Creates a filesystem-backed keg under your first configured kegSearchPaths entry and + writes/updates the alias in user config. + +2. local (--project, --cwd, or --path) + Creates a local filesystem-backed keg. By default this resolves to + /kegs/, + where is the git root when available. Use --cwd to base it on the + current working directory instead, or use --path to set an explicit + location. --path implies a local destination even when --project is not + passed. + +3. hub (--hub ) + Creates a hub/API keg target named and stores it in config without + creating local keg files. The hub name is required when --hub is used. + +Alias behavior: +- --keg sets the alias written to config and the directory name. +- If --keg is omitted, alias is inferred from the current working directory basename. + +Metadata: +- --title and --creator are written into the keg config for filesystem-backed kegs. + +Interactive mode: +- When stdin is a TTY and no destination/alias flags are provided, tap init + prompts for the alias, location category, title, and creator. Pass + --non-interactive to skip the prompt and rely on flag-driven defaults + (e.g. for CI or scripted invocations). +`), + Example: strings.TrimSpace(` +tap init --keg blog +tap init --project --cwd +tap init --keg blog --cwd +tap init --keg blog --path ./kegs/blog +tap init --keg blog --user +tap init --keg blog --hub knut --namespace me +`), + RunE: func(cmd *cobra.Command, args []string) error { + if shouldPromptInit(deps, &initOpts) { + if err := promptInitOptions(cmd, deps, &initOpts); err != nil { + return err + } + } + + if strings.TrimSpace(initOpts.Keg) == "" { + cwd, err := deps.Runtime.Getwd() + if err != nil { + return fmt.Errorf("unable to determine working directory for alias inference: %w", err) + } + initOpts.Keg = filepath.Base(cwd) + } + + target, err := deps.Tap.InitKeg(cmd.Context(), initOpts) + if err != nil { + return err + } + + label := tapper.KegBackendLabel(target) + if label == "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created", initOpts.Keg) + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created (%s)", initOpts.Keg, label) + return err + }, + } + + cmd.Flags().BoolVar(&initOpts.Project, "project", false, "create a project-local keg") + cmd.Flags().BoolVar(&initOpts.User, "user", false, "create a user keg under the first configured kegSearchPaths entry") + cmd.Flags().StringVar(&initOpts.Hub, "hub", "", "hub name (selects API-style hub target when set)") + cmd.Flags().BoolVar(&initOpts.Cwd, "cwd", false, "use cwd instead of git root for local destination resolution") + cmd.Flags().StringVar(&initOpts.Path, "path", "", "explicit local destination path; implies local mode") + cmd.Flags().StringVar(&initOpts.UserName, "namespace", "", "hub namespace/user to use with --hub") + cmd.Flags().StringVarP(&initOpts.Keg, "keg", "k", "", "alias of keg to add to config") + cmd.Flags().StringVar(&initOpts.Title, "title", "", "human title to write into the keg config") + cmd.Flags().StringVar(&initOpts.Creator, "creator", "", "creator identifier to include in the keg config") + cmd.Flags().StringVar(&initOpts.TokenEnv, "token-env", "", "environment variable name to store token reference (API targets)") + cmd.Flags().BoolVar(&initOpts.NonInteractive, "non-interactive", false, "skip the interactive prompt even when stdin is a TTY") + + return cmd +} + +// shouldPromptInit reports whether the cobra RunE handler should fire the +// interactive `tap init` prompt. The prompt is gated on three conditions: +// stdin is a TTY, --non-interactive is not set, and the user has supplied no +// destination flags or alias on the command line. Any explicit flag means the +// user has already declared their intent; only the bare `tap init` invocation +// triggers the conversational path. +func shouldPromptInit(deps *Deps, opts *tapper.InitOptions) bool { + if deps == nil || deps.Runtime == nil { + return false + } + if !deps.Runtime.Stream().IsTTY { + return false + } + if opts.NonInteractive { + return false + } + if opts.User || opts.Project || opts.Cwd { + return false + } + if strings.TrimSpace(opts.Path) != "" || strings.TrimSpace(opts.Hub) != "" { + return false + } + if strings.TrimSpace(opts.Keg) != "" { + return false + } + return true +} + +// promptInitOptions walks the user through alias / location / metadata when +// `tap init` is invoked bare on a TTY. Prompts go to stderr (so stdout stays +// clean for the success line that downstream tooling may pipe), and answers +// come from cmd.InOrStdin() so tests can pipe scripted answers via +// Process.RunWithIO. +// +// The hub branch is intentionally skipped: hub init still requires the user +// to pass --hub explicitly, since hub setup needs a namespace + token and the +// terse prompt is not the right place to teach that flow. +func promptInitOptions(cmd *cobra.Command, deps *Deps, opts *tapper.InitOptions) error { + reader := bufio.NewReader(cmd.InOrStdin()) + stderr := cmd.ErrOrStderr() + + defaultAlias := "" + if deps != nil && deps.Runtime != nil { + if cwd, err := deps.Runtime.Getwd(); err == nil && cwd != "" { + defaultAlias = filepath.Base(cwd) + } + } + + alias, err := promptLine(stderr, reader, fmt.Sprintf("keg alias [%s]: ", defaultAlias)) + if err != nil { + return err + } + if alias == "" { + alias = defaultAlias + } + if err := tapper.ValidateKegAlias(alias); err != nil { + return err + } + opts.Keg = alias + + location, err := promptLine(stderr, reader, "location [user/project] (default user): ") + if err != nil { + return err + } + switch strings.ToLower(strings.TrimSpace(location)) { + case "", "user", "u": + opts.User = true + case "project", "p": + opts.Project = true + default: + return fmt.Errorf("invalid location %q: expected user or project", location) + } + + title, err := promptLine(stderr, reader, "title (optional): ") + if err != nil { + return err + } + if title != "" { + opts.Title = title + } + + creator, err := promptLine(stderr, reader, "creator (optional): ") + if err != nil { + return err + } + if creator != "" { + opts.Creator = creator + } + + return nil +} + +// promptLine writes prompt to w, reads a single line from r, and returns the +// trimmed answer. Treats io.EOF as a terminating empty answer so a piped +// stdin that closes after fewer responses than prompts behaves as if each +// remaining prompt accepted its default. +func promptLine(w io.Writer, r *bufio.Reader, prompt string) (string, error) { + if _, err := fmt.Fprint(w, prompt); err != nil { + return "", err + } + line, err := r.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimSpace(line), nil +} diff --git a/pkg/cli/cmd_repo_init_test.go b/pkg/cli/cmd_init_test.go similarity index 66% rename from pkg/cli/cmd_repo_init_test.go rename to pkg/cli/cmd_init_test.go index 787de5e..8f90c3b 100644 --- a/pkg/cli/cmd_repo_init_test.go +++ b/pkg/cli/cmd_init_test.go @@ -2,6 +2,7 @@ package cli_test import ( "path/filepath" + "strings" "testing" testutils "github.com/jlrickert/cli-toolkit/sandbox" @@ -25,7 +26,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_named_project_defaults_to_kegs_alias", args: []string{ - "repo", "init", + "init", "--project", "--keg", "power", "--creator", "me", @@ -33,15 +34,14 @@ func TestInitCommand_TableDriven(t *testing.T) { expectedAlias: "power", expectedLocation: "~/kegs/power", expectedStdout: []string{ - "keg power created at", - "/kegs/power", + "keg power created (file-backed)", }, description: "When --project, destination should default to kegs/ under project root", }, { name: "local_keg_with_cwd_without_project", args: []string{ - "repo", "init", + "init", "--cwd", "--keg", "power", "--creator", "me", @@ -49,8 +49,7 @@ func TestInitCommand_TableDriven(t *testing.T) { expectedAlias: "power", expectedLocation: "~/myproject/kegs/power", expectedStdout: []string{ - "keg power created at", - "/myproject/kegs/power", + "keg power created (file-backed)", }, cwd: strPtr("~/myproject"), description: "When --cwd is set without --project, destination should still resolve as a local keg under the current working directory", @@ -58,7 +57,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_with_path_without_project", args: []string{ - "repo", "init", + "init", "--path", ".", "--keg", "workspace", "--creator", "me", @@ -66,8 +65,7 @@ func TestInitCommand_TableDriven(t *testing.T) { expectedAlias: "workspace", expectedLocation: "~/myproject", expectedStdout: []string{ - "keg workspace created at", - "/myproject", + "keg workspace created (file-backed)", }, cwd: strPtr("~/myproject"), description: "When --path is set without --project, destination should resolve as a local keg at the explicit path", @@ -75,7 +73,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_with_explicit_alias", args: []string{ - "repo", "init", + "init", "--project", "--keg", "myalias", "--creator", "me", @@ -87,7 +85,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_infers_alias_from_cwd", args: []string{ - "repo", "init", + "init", "--project", "--creator", "me", }, @@ -99,7 +97,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_project_explicit_alias", args: []string{ - "repo", "init", + "init", "--project", "--keg", "myalias", "--creator", "me", @@ -111,7 +109,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_keg_defaults_to_user_type", args: []string{ - "repo", "init", + "init", "--keg", "public", "--creator", "testcreator", }, @@ -124,7 +122,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_keg_with_explicit_type", args: []string{ - "repo", "init", + "init", "--user", "--keg", "public", "--creator", "testcreator", @@ -138,7 +136,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_keg_with_explicit_alias", args: []string{ - "repo", "init", + "init", "--keg", "myblog", "--creator", "me", }, @@ -151,7 +149,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_type_infers_alias_from_cwd", args: []string{ - "repo", "init", + "init", "--user", "--creator", "me", }, @@ -268,7 +266,7 @@ func TestInitCommand_DestinationValidation(t *testing.T) { innerT.Parallel() sb := NewSandbox(innerT) - h := NewProcess(innerT, false, "repo", "init", "--keg", "blog", "--project", "--user") + h := NewProcess(innerT, false, "init", "--keg", "blog", "--project", "--user") res := h.Run(sb.Context(), sb.Runtime()) require.Error(innerT, res.Err) @@ -279,10 +277,112 @@ func TestInitCommand_DestinationValidation(t *testing.T) { innerT.Parallel() sb := NewSandbox(innerT) - h := NewProcess(innerT, false, "repo", "init", "--keg", "blog", "--cwd", "--user") + h := NewProcess(innerT, false, "init", "--keg", "blog", "--cwd", "--user") res := h.Run(sb.Context(), sb.Runtime()) require.Error(innerT, res.Err) require.Contains(innerT, string(res.Stderr), "only one destination may be selected") }) } + +func TestInitCommand_FallsBackToPlatformDefaultWhenSearchPathsUnset(t *testing.T) { + t.Parallel() + + sb := NewSandbox(t) + + h := NewProcess(t, false, "init", "--user", "--keg", "fresh", "--creator", "me") + res := h.Run(sb.Context(), sb.Runtime()) + + require.NoError(t, res.Err, "init should succeed without preconfigured kegSearchPaths") + require.Contains(t, string(res.Stdout), "keg fresh created") + + keg := sb.MustReadFile("~/.local/share/tapper/kegs/fresh/keg") + require.Contains(t, string(keg), "$schema=", + "keg config should have been written under platform default data dir") +} + +func TestInitCommand_RejectsInvalidAlias(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + alias string + }{ + {"uppercase", "Blog"}, + {"space", "my blog"}, + {"slash", "kegs/blog"}, + {"dot", "blog.keg"}, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(innerT *testing.T) { + innerT.Parallel() + sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) + + h := NewProcess(innerT, false, "init", "--user", "--keg", c.alias) + res := h.Run(sb.Context(), sb.Runtime()) + + require.Error(innerT, res.Err) + require.Contains(innerT, string(res.Stderr), "invalid keg alias") + }) + } +} + +// TestInitCommand_InteractivePrompt covers the TTY-gated prompt path: +// when stdin is a TTY and no destination flags are supplied, tap init +// asks for alias / location / title / creator. We pipe scripted answers +// via RunWithIO and assert that the resulting keg uses the alias from +// the prompt (not cwd basename) and lands in the user destination. +func TestInitCommand_InteractivePrompt(t *testing.T) { + t.Parallel() + sb := NewSandbox(t) + + answers := strings.Join([]string{ + "diary", // alias + "user", // location + "My Diary", // title + "me@example", // creator + "", // trailing newline buffer + }, "\n") + + h := NewProcess(t, true, "init") + res := h.RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader(answers)) + require.NoError(t, res.Err, "interactive init should succeed: stderr=%q", string(res.Stderr)) + require.Contains(t, string(res.Stdout), "keg diary created (file-backed)") + + keg := sb.MustReadFile("~/.local/share/tapper/kegs/diary/keg") + require.Contains(t, string(keg), "$schema=", "interactive init should have written the platform-default user keg") + require.Contains(t, string(keg), "title: My Diary") + require.Contains(t, string(keg), "creator: me@example") +} + +// TestInitCommand_NonInteractiveFlagSkipsPrompt confirms that +// --non-interactive bypasses the TTY prompt even when stdin is a TTY, +// so scripted invocations on attended terminals can rely on flag +// defaults without piping answers. +func TestInitCommand_NonInteractiveFlagSkipsPrompt(t *testing.T) { + t.Parallel() + sb := NewSandbox(t) + + h := NewProcess(t, true, "init", "--non-interactive", "--keg", "ci", "--user", "--creator", "ci-bot") + res := h.Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, "init --non-interactive on TTY should succeed without piped stdin") + require.Contains(t, string(res.Stdout), "keg ci created (file-backed)") + require.NotContains(t, string(res.Stderr), "keg alias [") +} + +// TestInitCommand_NonTTYSkipsPrompt confirms that bare `tap init` +// without a TTY (CI, MCP, pipes) does not block waiting for prompt +// answers — it falls back to alias inference from cwd and the platform +// default user destination. This is the behavior MCP relies on. +func TestInitCommand_NonTTYSkipsPrompt(t *testing.T) { + t.Parallel() + sb := NewSandbox(t) + require.NoError(t, sb.Setwd("/home/testuser/scratch")) + + h := NewProcess(t, false, "init") + res := h.Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, "non-TTY bare init should succeed via cwd-inferred alias") + require.Contains(t, string(res.Stdout), "keg scratch created (file-backed)") +} diff --git a/pkg/cli/cmd_repo.go b/pkg/cli/cmd_repo.go index 4baed56..48c5c89 100644 --- a/pkg/cli/cmd_repo.go +++ b/pkg/cli/cmd_repo.go @@ -16,7 +16,6 @@ func NewRepoCmd(deps *Deps) *cobra.Command { cmd.AddCommand( NewRepoConfigCmd(deps), NewRepoKegListCmd(deps), - NewInitCmd(deps), NewRepoRmCmd(deps), ) diff --git a/pkg/cli/cmd_repo_config_test.go b/pkg/cli/cmd_repo_config_test.go index 5e2dab7..f23d4cf 100644 --- a/pkg/cli/cmd_repo_config_test.go +++ b/pkg/cli/cmd_repo_config_test.go @@ -106,7 +106,7 @@ func TestConfigCommand_IntegrationWithInit(t *testing.T) { // First, initialize a user keg initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--user", "--keg", "newstudy", "--creator", "test-user", diff --git a/pkg/cli/cmd_repo_init.go b/pkg/cli/cmd_repo_init.go deleted file mode 100644 index cd761b6..0000000 --- a/pkg/cli/cmd_repo_init.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/spf13/cobra" -) - -// NewInitCmd returns the `tap repo init` cobra command. -// -// Usage examples: -// -// tap repo init --keg blog -// tap repo init --project -// tap repo init --keg blog --cwd -// tap repo init --keg blog --hub knut --namespace me -// tap repo init --keg blog --path ./kegs/blog --title "Blog" --creator "me" -func NewInitCmd(deps *Deps) *cobra.Command { - initOpts := tapper.InitOptions{} - - cmd := &cobra.Command{ - Use: "init", - Short: "create a new keg target", - Args: cobra.NoArgs, - Long: strings.TrimSpace(` -Create a keg target and initialize it in one of three destinations: - -1. user (default) - Creates a filesystem-backed keg under your first configured kegSearchPaths entry and - writes/updates the alias in user config. - -2. local (--project, --cwd, or --path) - Creates a local filesystem-backed keg. By default this resolves to - /kegs/, - where is the git root when available. Use --cwd to base it on the - current working directory instead, or use --path to set an explicit - location. --path implies a local destination even when --project is not - passed. - -3. hub (--hub ) - Creates a hub/API keg target named and stores it in config without - creating local keg files. The hub name is required when --hub is used. - -Alias behavior: -- --keg sets the alias written to config and the directory name. -- If --keg is omitted, alias is inferred from the current working directory basename. - -Metadata: -- --title and --creator are written into the keg config for filesystem-backed kegs. -`), - Example: strings.TrimSpace(` -tap repo init --keg blog -tap repo init --project --cwd -tap repo init --keg blog --cwd -tap repo init --keg blog --path ./kegs/blog -tap repo init --keg blog --user -tap repo init --keg blog --hub knut --namespace me -`), - RunE: func(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(initOpts.Keg) == "" { - cwd, err := deps.Runtime.Getwd() - if err != nil { - return fmt.Errorf("unable to determine working directory for alias inference: %w", err) - } - initOpts.Keg = filepath.Base(cwd) - } - - target, err := deps.Tap.InitKeg(cmd.Context(), initOpts) - if err != nil { - return err - } - - if initOpts.LocalDestination() && target != nil { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created at %s", initOpts.Keg, target.Path()) - return err - } - - _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created", initOpts.Keg) - return err - }, - } - - cmd.Flags().BoolVar(&initOpts.Project, "project", false, "create a project-local keg") - cmd.Flags().BoolVar(&initOpts.User, "user", false, "create a user keg under the first configured kegSearchPaths entry") - cmd.Flags().StringVar(&initOpts.Hub, "hub", "", "hub name (selects API-style hub target when set)") - cmd.Flags().BoolVar(&initOpts.Cwd, "cwd", false, "use cwd instead of git root for local destination resolution") - cmd.Flags().StringVar(&initOpts.Path, "path", "", "explicit local destination path; implies local mode") - cmd.Flags().StringVar(&initOpts.UserName, "namespace", "", "hub namespace/user to use with --hub") - cmd.Flags().StringVarP(&initOpts.Keg, "keg", "k", "", "alias of keg to add to config") - cmd.Flags().StringVar(&initOpts.Title, "title", "", "human title to write into the keg config") - cmd.Flags().StringVar(&initOpts.Creator, "creator", "", "creator identifier to include in the keg config") - cmd.Flags().StringVar(&initOpts.TokenEnv, "token-env", "", "environment variable name to store token reference (API targets)") - - return cmd -} diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index ceb10ab..8e143a1 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -261,7 +261,6 @@ func NewRootCmd(deps *Deps) *cobra.Command { NewMoveCmd(deps), NewOrientCmd(deps), NewSnapshotCmd(deps), - NewPwdCmd(deps), NewRemoveCmd(deps), NewSiteCmd(deps), NewStatsCmd(deps), @@ -272,14 +271,23 @@ func NewRootCmd(deps *Deps) *cobra.Command { subcommands = append(subcommands, NewConfigCmd(deps)) } var repoCmd *cobra.Command + var initCmd *cobra.Command if deps.Profile.IncludeRepoCommand { repoCmd = NewRepoCmd(deps) - subcommands = append(subcommands, repoCmd) + initCmd = NewInitCmd(deps) + subcommands = append(subcommands, repoCmd, initCmd) } cmd.AddCommand(subcommands...) if repoCmd != nil { filterRepoTargetFlagsInHelp(repoCmd) } + // `tap init` re-binds --keg/--project/--path/--cwd locally with + // create-time semantics. Strip the inherited keg-target persistent + // flags from its "Global Flags" help section so users don't see two + // entries for each name. + if initCmd != nil && deps.Profile.withDefaults().AllowKegAliasFlags { + filterRepoTargetFlagsInHelp(initCmd) + } return cmd } diff --git a/pkg/cli/profile_resolve_test.go b/pkg/cli/profile_resolve_test.go index 51be709..c7af1f1 100644 --- a/pkg/cli/profile_resolve_test.go +++ b/pkg/cli/profile_resolve_test.go @@ -14,7 +14,7 @@ func TestTap_ProjectResolutionFlags(t *testing.T) { sb.Setwd("~") initCmd := NewProcess(t, false, - "repo", "init", + "init", "--project", "--cwd", "--keg", "project", @@ -52,7 +52,7 @@ func TestTap_AliasResolvesProjectKegUnderKegsDir(t *testing.T) { sb.Setwd("~/myproject") initCmd := NewProcess(t, false, - "repo", "init", + "init", "--project", "--cwd", "--keg", "tapper", @@ -90,7 +90,7 @@ func TestKeg_UsesProjectKegOnly(t *testing.T) { sb.Setwd("~") legacyInit := NewProcess(innerT, false, - "repo", "init", + "init", "--project", "--path", "~/docs", "--keg", "legacy", @@ -113,7 +113,7 @@ func TestKeg_UsesProjectKegOnly(t *testing.T) { sb.Setwd("~") initCmd := NewProcess(innerT, false, - "repo", "init", + "init", "--project", "--cwd", "--keg", "project", @@ -150,7 +150,7 @@ func TestTap_CwdStandaloneResolution(t *testing.T) { sb.Setwd("~") initCmd := NewProcess(t, false, - "repo", "init", + "init", "--cwd", "--keg", "project", "--creator", "test-user", diff --git a/pkg/keg/dex.go b/pkg/keg/dex.go index a94f89d..dae6ff4 100644 --- a/pkg/keg/dex.go +++ b/pkg/keg/dex.go @@ -51,8 +51,10 @@ type DexOption func(*Dex) error // - has a non-empty Query (or deprecated Tags) field, and // - is not one of the core protected index names. // -// The short file name used with repo.WriteIndex is derived by stripping any -// leading "dex/" prefix from entry.File. +// IndexEntry.File is canonically the bare filename (e.g. "concepts.md"); +// ParseKegConfig and applyDefaults strip any legacy "dex/" prefix at parse +// time. The TrimPrefix below is defensive for inline-constructed entries +// that may still carry the legacy prefix. // // By default, the index evaluates tag expressions against node tag sets. To // support richer query terms (e.g. key=value attribute predicates), pass @@ -70,7 +72,8 @@ func WithConfig(cfg *Config) DexOption { if query == "" { continue } - // Strip the "dex/" prefix to get the short name for repo.WriteIndex. + // Defensive: strip any "dex/" prefix in case the entry was + // constructed inline rather than parsed via ParseKegConfig. shortName := strings.TrimPrefix(entry.File, "dex/") sortOrder := QueryFilteredSortOrder(entry.Sort) idx, err := NewQueryFilteredIndexWithSort(shortName, query, d.queryResolver, sortOrder) diff --git a/pkg/keg/dex_changes.go b/pkg/keg/dex_changes.go index af0c50c..641c4aa 100644 --- a/pkg/keg/dex_changes.go +++ b/pkg/keg/dex_changes.go @@ -193,22 +193,23 @@ func (idx *ChangesIndex) Data(ctx context.Context) ([]byte, error) { // TagFilteredIndex // -------------------------------------------------------------------------- -// coreIndexNames is the set of built-in index names (using the dex/ prefix as -// used in keg config Indexes entries) that cannot be overridden by -// config-driven tag-filtered indexes. +// coreIndexNames is the set of built-in index filenames (in canonical bare +// form, without the "dex/" prefix) that cannot be overridden by config-driven +// tag-filtered indexes. var coreIndexNames = map[string]bool{ - "dex/changes.md": true, - "dex/nodes.tsv": true, - "dex/links": true, - "dex/backlinks": true, - "dex/tags": true, + "changes.md": true, + "nodes.tsv": true, + "links": true, + "backlinks": true, + "tags": true, } -// IsCoreIndex reports whether the given index file path (as used in a keg -// config Indexes entry, e.g. "dex/changes.md") is one of the built-in -// protected index names. +// IsCoreIndex reports whether the given index file name is one of the +// built-in protected index names. Both the canonical bare form +// ("changes.md") and the legacy prefixed form ("dex/changes.md") are +// accepted; the prefix is stripped before lookup. func IsCoreIndex(name string) bool { - return coreIndexNames[name] + return coreIndexNames[strings.TrimPrefix(name, "dex/")] } // TagFilteredIndex is an in-memory index of nodes that match a boolean tag diff --git a/pkg/keg/dex_test.go b/pkg/keg/dex_test.go index 2a582a8..1b7a872 100644 --- a/pkg/keg/dex_test.go +++ b/pkg/keg/dex_test.go @@ -343,6 +343,48 @@ func TestTagIndex_Add_NoTagsRemovesAll(t *testing.T) { require.Len(t, idx.data, 0, "all tags should be removed when node has no tags") } +// TestIsCoreIndex_BothForms verifies that IsCoreIndex accepts both the +// canonical bare form and the legacy "dex/"-prefixed form. +func TestIsCoreIndex_BothForms(t *testing.T) { + t.Parallel() + for _, name := range []string{"changes.md", "nodes.tsv", "links", "backlinks", "tags"} { + require.True(t, IsCoreIndex(name), "bare form %q should be a core index", name) + require.True(t, IsCoreIndex("dex/"+name), "prefixed form dex/%s should be a core index", name) + } + require.False(t, IsCoreIndex("custom.md")) + require.False(t, IsCoreIndex("dex/custom.md")) +} + +// TestDex_WithConfig_BareFormCustomIndex verifies that a bare-form custom +// index entry (no "dex/" prefix) creates a custom index that lands at +// dex/ on disk via repo.WriteIndex. +func TestDex_WithConfig_BareFormCustomIndex(t *testing.T) { + t.Parallel() + + rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") + require.NoError(t, err) + mem := NewMemoryRepo(rt) + + cfg := &Config{ + Indexes: []IndexEntry{ + {File: "golang.md", Summary: "Go nodes", Query: "golang"}, + }, + } + + dex, err := NewDexFromRepo(t.Context(), mem, WithConfig(cfg)) + require.NoError(t, err) + require.Len(t, dex.custom, 1) + + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + goNode := makeNodeData(10, "Go patterns", []string{"golang"}, t1) + require.NoError(t, dex.Add(t.Context(), goNode)) + require.NoError(t, dex.Write(t.Context(), mem)) + + raw, err := mem.GetIndex(t.Context(), "golang.md") + require.NoError(t, err) + require.Contains(t, string(raw), "Go patterns") +} + // TestDex_WithConfig_CoreIndexSkipped verifies that core index names in // cfg.Indexes with Tags set are not added as custom tag-filtered indexes. func TestDex_WithConfig_CoreIndexSkipped(t *testing.T) { diff --git a/pkg/keg/indexes.go b/pkg/keg/indexes.go index 915bf27..ca265f1 100644 --- a/pkg/keg/indexes.go +++ b/pkg/keg/indexes.go @@ -7,7 +7,9 @@ import "context" // in-memory state via Add / Remove / Clear and produce the serialized bytes to // write via Data. type IndexBuilder interface { - // Name returns the canonical index filename (for example "dex/tags"). + // Name returns the bare index filename used with repo.WriteIndex + // (for example "tags" or "concepts.md"). The "dex/" directory prefix + // is implicit and applied by the repository at write time. Name() string // Add incorporates information from a node into the index's in-memory state. diff --git a/pkg/keg/keg_config.go b/pkg/keg/keg_config.go index b80397c..740d670 100644 --- a/pkg/keg/keg_config.go +++ b/pkg/keg/keg_config.go @@ -129,6 +129,13 @@ type LinkEntry struct { } // IndexEntry represents an entry in the indexes list in the KEG configuration. +// +// File is the bare filename of the generated index artifact, e.g. "backlinks" +// or "concepts.md". The on-disk path is always under the keg's dex/ directory +// (the prefix is implicit and applied at write time). For backward +// compatibility, a leading "dex/" in the parsed YAML is stripped during +// ParseKegConfig and applyDefaults so callers consistently see the bare form. +// // The Query field holds a boolean query expression used to filter index // contents (tag names, key=value attribute predicates, boolean operators). // The deprecated Tags field is accepted for backward compatibility; Query @@ -141,6 +148,22 @@ type IndexEntry struct { Sort string `yaml:"sort,omitempty"` // sort order for query-filtered indexes: "updated" (default), "id", "created", "accessed" } +// normalizeIndexFile returns the bare filename for an index entry, stripping +// a leading "dex/" prefix if present. This canonicalizes both the legacy +// prefixed form ("dex/backlinks") and the new bare form ("backlinks") to a +// single representation in the in-memory struct. +func normalizeIndexFile(file string) string { + return strings.TrimPrefix(file, "dex/") +} + +// normalizeIndexEntries canonicalizes the File field of each entry by +// stripping any leading "dex/" prefix. +func normalizeIndexEntries(entries []IndexEntry) { + for i := range entries { + entries[i].File = normalizeIndexFile(entries[i].File) + } +} + // QueryOrTags returns the effective query string for the index entry. It // prefers Query when set, falling back to the deprecated Tags field. func (ie *IndexEntry) QueryOrTags() string { @@ -201,19 +224,19 @@ func NewConfig(options ...ConfigOption) *Config { Timezone: "UTC", Indexes: []IndexEntry{ { - File: "dex/backlinks", Summary: "all incoming links", + File: "backlinks", Summary: "all incoming links", }, { - File: "dex/changes.md", Summary: "latest changes", + File: "changes.md", Summary: "latest changes", }, { - File: "dex/links", Summary: "all outgoing links", + File: "links", Summary: "all outgoing links", }, { - File: "dex/nodes.tsv", Summary: "all nodes by id", + File: "nodes.tsv", Summary: "all nodes by id", }, { - File: "dex/tags", Summary: "all tags", + File: "tags", Summary: "all tags", }, }, } @@ -262,11 +285,13 @@ func ParseKegConfig(data []byte) (*Config, error) { return &configV2, nil } -// applyDefaults fills in zero-value fields with their documented defaults. +// applyDefaults fills in zero-value fields with their documented defaults +// and normalizes index entry filenames to the canonical bare form. func (kc *ConfigV2) applyDefaults() { if kc.Timezone == "" { kc.Timezone = "UTC" } + normalizeIndexEntries(kc.Indexes) } // Location returns the *time.Location for the configured Timezone. diff --git a/pkg/keg/keg_config_test.go b/pkg/keg/keg_config_test.go index f63e4cc..b20c9ef 100644 --- a/pkg/keg/keg_config_test.go +++ b/pkg/keg/keg_config_test.go @@ -341,6 +341,91 @@ indexes: require.Equal(t, "entity=concept && golang", config.Indexes[0].QueryOrTags(), "Query should take precedence") } +func TestParseConfigV2_IndexFileNormalizesDexPrefix(t *testing.T) { + yamlData := ` +kegv: "2025-07" +title: "Legacy prefixed indexes" +indexes: + - file: "dex/backlinks" + summary: "all incoming links" + - file: "dex/concepts.md" + summary: "concept nodes" + query: "entity=concept" +` + config, err := keg.ParseKegConfig([]byte(yamlData)) + require.NoError(t, err) + require.Len(t, config.Indexes, 2) + require.Equal(t, "backlinks", config.Indexes[0].File, "leading dex/ should be stripped at parse") + require.Equal(t, "concepts.md", config.Indexes[1].File, "leading dex/ should be stripped at parse") +} + +func TestParseConfigV2_IndexFileBareForm(t *testing.T) { + yamlData := ` +kegv: "2025-07" +title: "Bare-form indexes" +indexes: + - file: "backlinks" + summary: "all incoming links" + - file: "concepts.md" + summary: "concept nodes" + query: "entity=concept" +` + config, err := keg.ParseKegConfig([]byte(yamlData)) + require.NoError(t, err) + require.Len(t, config.Indexes, 2) + require.Equal(t, "backlinks", config.Indexes[0].File) + require.Equal(t, "concepts.md", config.Indexes[1].File) +} + +func TestParseConfigV1_IndexFileNormalizesDexPrefix(t *testing.T) { + v1Yaml := ` +kegv: "2023-01" +title: "V1 legacy prefixed" +indexes: + - file: "dex/backlinks" + summary: "all incoming links" +` + config, err := keg.ParseKegConfig([]byte(v1Yaml)) + require.NoError(t, err) + require.Len(t, config.Indexes, 1) + require.Equal(t, "backlinks", config.Indexes[0].File, "V1 → V2 migration should normalize index file paths") +} + +func TestNewConfig_DefaultIndexesAreBareForm(t *testing.T) { + cfg := keg.NewConfig() + require.NotEmpty(t, cfg.Indexes) + for _, entry := range cfg.Indexes { + require.NotEmpty(t, entry.File) + require.False(t, strings.HasPrefix(entry.File, "dex/"), + "default indexes should use bare form, got %q", entry.File) + } +} + +func TestParseConfig_RoundTripCanonicalizesIndexFile(t *testing.T) { + yamlData := ` +kegv: "2025-07" +title: "Round-trip test" +indexes: + - file: "dex/backlinks" + summary: "all incoming links" + - file: "concepts.md" + summary: "concept nodes" + query: "entity=concept" +` + first, err := keg.ParseKegConfig([]byte(yamlData)) + require.NoError(t, err) + + out, err := first.ToYAML() + require.NoError(t, err) + require.NotContains(t, string(out), "file: dex/", "ToYAML should emit bare-form index file paths") + + second, err := keg.ParseKegConfig(out) + require.NoError(t, err) + require.Len(t, second.Indexes, 2) + require.Equal(t, "backlinks", second.Indexes[0].File) + require.Equal(t, "concepts.md", second.Indexes[1].File) +} + func TestParseConfigV2_TimezoneDefaultsToUTC(t *testing.T) { yamlData := ` kegv: "2025-07" diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 506ebba..4b23fed 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -135,7 +135,6 @@ func TestMCP_ToolsList(t *testing.T) { require.Contains(t, names, "info") require.Contains(t, names, "keg_info") require.Contains(t, names, "stats") - require.Contains(t, names, "dir") require.Contains(t, names, "create") require.Contains(t, names, "edit") require.Contains(t, names, "meta") @@ -1531,7 +1530,7 @@ func TestMCP_ToolAnnotations_AllPresent(t *testing.T) { // --- read-only tools --- readOnlyTools := []string{ "cat", "list", "grep", "tags", "backlinks", "links", - "list_kegs", "info", "keg_info", "stats", "dir", + "list_kegs", "info", "keg_info", "stats", "list_files", "list_images", "list_indexes", "index_cat", "doctor", "lock_status", "license", "node_history", diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index 142fa0e..ada3319 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -19,7 +19,6 @@ func registerReadTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults registerInfo(srv, tap, defaults) registerKegInfo(srv, tap, defaults) registerStats(srv, tap, defaults) - registerDir(srv, tap, defaults) } // --- cat --- @@ -362,31 +361,3 @@ func registerStats(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { return textResult(result), nil, nil }) } - -// --- dir --- - -type dirInput struct { - NodeID string `json:"node_id,omitempty" jsonschema:"node ID (omit for keg root directory)"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` -} - -func registerDir(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "dir", - Description: "Show the filesystem path of a keg or node directory", - Annotations: &sdkmcp.ToolAnnotations{ - ReadOnlyHint: true, - OpenWorldHint: boolPtr(false), - }, - }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in dirInput) (*sdkmcp.CallToolResult, any, error) { - opts := tapper.DirOptions{ - KegTargetOptions: resolveKegTarget(in.Keg, defaults), - NodeID: in.NodeID, - } - result, err := tap.Dir(ctx, opts) - if err != nil { - return errorResult(err), nil, nil - } - return textResult(result), nil, nil - }) -} diff --git a/pkg/mcp/tools_repo.go b/pkg/mcp/tools_repo.go index 09a8bdf..b61aac0 100644 --- a/pkg/mcp/tools_repo.go +++ b/pkg/mcp/tools_repo.go @@ -20,12 +20,13 @@ func registerRepoTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults // --- repo_init --- type repoInitInput struct { - Keg string `json:"keg" jsonschema:"keg alias for the new repository"` - User bool `json:"user,omitempty" jsonschema:"create under user keg search path (default true)"` - Project bool `json:"project,omitempty" jsonschema:"create under project path"` - Path string `json:"path,omitempty" jsonschema:"explicit filesystem path (implies project destination)"` - Title string `json:"title,omitempty" jsonschema:"keg title"` - Creator string `json:"creator,omitempty" jsonschema:"keg creator identifier"` + Keg string `json:"keg" jsonschema:"keg alias for the new repository"` + User bool `json:"user,omitempty" jsonschema:"create under user keg search path (default true)"` + Project bool `json:"project,omitempty" jsonschema:"create under project path"` + Path string `json:"path,omitempty" jsonschema:"explicit filesystem path (implies project destination)"` + Title string `json:"title,omitempty" jsonschema:"keg title"` + Creator string `json:"creator,omitempty" jsonschema:"keg creator identifier"` + NonInteractive bool `json:"non_interactive,omitempty" jsonschema:"skip interactive prompts (always true in MCP context)"` } func registerRepoInit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { @@ -38,13 +39,15 @@ func registerRepoInit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) }, }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in repoInitInput) (*sdkmcp.CallToolResult, any, error) { opts := tapper.InitOptions{ - Keg: in.Keg, - User: in.User, - Project: in.Project, - Path: in.Path, - Title: in.Title, - Creator: in.Creator, + Keg: in.Keg, + User: in.User, + Project: in.Project, + Path: in.Path, + Title: in.Title, + Creator: in.Creator, + NonInteractive: true, } + _ = in.NonInteractive // MCP never prompts; field exists for parity with the CLI flag // Default to user destination when nothing else is set. if !opts.User && !opts.Project && opts.Path == "" { @@ -55,7 +58,11 @@ func registerRepoInit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) if err != nil { return errorResult(err), nil, nil } - return textResult(fmt.Sprintf("initialized keg %q at %s", in.Keg, target.String())), nil, nil + label := tapper.KegBackendLabel(target) + if label == "" { + return textResult(fmt.Sprintf("initialized keg %q", in.Keg)), nil, nil + } + return textResult(fmt.Sprintf("initialized keg %q (%s)", in.Keg, label)), nil, nil }) } diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 3208826..3dc399e 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -37,7 +37,6 @@ var tapMethodToSurfaces = map[string]struct { "Info": {CLI: "config", MCP: "info"}, "KegInfo": {CLI: "info", MCP: "keg_info"}, "Stats": {CLI: "stats", MCP: "stats"}, - "Dir": {CLI: "dir", MCP: "dir"}, "Graph": {CLI: "graph", MCP: "graph"}, "ListIndexes": {CLI: "index list", MCP: "list_indexes"}, "IndexCat": {CLI: "index get", MCP: "index_cat"}, @@ -75,7 +74,9 @@ var tapMethodToSurfaces = map[string]struct { "ForceUnlock": {CLI: "lock force-release", MCP: "lock_force_release"}, // Repo management - "InitKeg": {CLI: "repo init", MCP: "repo_init"}, + // MCP tool name kept as "repo_init" for backward compatibility with + // existing agent integrations; CLI surface is the top-level `tap init`. + "InitKeg": {CLI: "init", MCP: "repo_init"}, "RemoveRepo": {CLI: "repo rm", MCP: "repo_rm"}, // Config operations diff --git a/pkg/parity/parity_read_test.go b/pkg/parity/parity_read_test.go index 89a5713..895782f 100644 --- a/pkg/parity/parity_read_test.go +++ b/pkg/parity/parity_read_test.go @@ -22,7 +22,6 @@ import ( // tap config -> info (both call Tap.Info; MCP uses Minimal=true by default) // tap info -> keg_info (both call Tap.KegInfo) // tap stats -> stats (both call Tap.Stats) -// tap dir -> dir (both call Tap.Dir) func TestParity_ReadOperations(t *testing.T) { t.Parallel() @@ -431,36 +430,6 @@ func TestParity_ReadOperations(t *testing.T) { "limit": -1, }, }, - - // --- dir (Tap.Dir) --- - { - Name: "dir/keg_root", - CLIArgs: []string{"dir"}, - MCPTool: "dir", - MCPInput: map[string]any{}, - Compare: func(t *testing.T, cliOut, mcpOut string) { - t.Helper() - require.Contains(t, cliOut, "personal", "CLI dir should contain keg path") - require.Contains(t, mcpOut, "personal", "MCP dir should contain keg path") - require.Equal(t, strings.TrimSpace(cliOut), strings.TrimSpace(mcpOut), - "dir paths should be identical") - }, - }, - { - Name: "dir/node_path", - CLIArgs: []string{"dir", "0"}, - MCPTool: "dir", - MCPInput: map[string]any{ - "node_id": "0", - }, - Compare: func(t *testing.T, cliOut, mcpOut string) { - t.Helper() - require.Equal(t, strings.TrimSpace(cliOut), strings.TrimSpace(mcpOut), - "node dir paths should be identical") - require.True(t, strings.HasSuffix(strings.TrimSpace(cliOut), "/0"), - "path should end with /0") - }, - }, } runParityTests(t, cases) diff --git a/pkg/tapper/alias.go b/pkg/tapper/alias.go new file mode 100644 index 0000000..8f4800b --- /dev/null +++ b/pkg/tapper/alias.go @@ -0,0 +1,28 @@ +package tapper + +import ( + "fmt" + "regexp" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// kegAliasPattern restricts keg aliases to a portable, filesystem-safe shape. +// Lowercase letters, digits, hyphen, and underscore — no dots, slashes, +// whitespace, or case variants that differ across platforms (HFS+, FAT32). +var kegAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`) + +// ValidateKegAlias returns nil when alias matches the canonical alias shape +// and a wrapped keg.ErrInvalid otherwise. Empty input is rejected explicitly +// so callers can distinguish missing-alias errors from shape errors when +// reading the wrapped chain. +func ValidateKegAlias(alias string) error { + if alias == "" { + return fmt.Errorf("keg alias is required: %w", keg.ErrInvalid) + } + if !kegAliasPattern.MatchString(alias) { + return fmt.Errorf("invalid keg alias %q: must match %s: %w", + alias, kegAliasPattern.String(), keg.ErrInvalid) + } + return nil +} diff --git a/pkg/tapper/alias_test.go b/pkg/tapper/alias_test.go new file mode 100644 index 0000000..fe6a50c --- /dev/null +++ b/pkg/tapper/alias_test.go @@ -0,0 +1,49 @@ +package tapper_test + +import ( + "errors" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestValidateKegAlias(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + alias string + ok bool + }{ + {"lowercase", "blog", true}, + {"digits", "keg42", true}, + {"hyphen", "my-keg", true}, + {"underscore", "my_keg", true}, + {"mixed_allowed", "k_3-b_2", true}, + {"single_char", "a", true}, + {"empty", "", false}, + {"uppercase", "Blog", false}, + {"space", "my keg", false}, + {"slash", "kegs/blog", false}, + {"dot", "blog.keg", false}, + {"plus", "a+b", false}, + {"unicode", "kég", false}, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + err := tapper.ValidateKegAlias(c.alias) + if c.ok { + require.NoError(t, err) + return + } + require.Error(t, err) + require.True(t, errors.Is(err, keg.ErrInvalid), + "expected keg.ErrInvalid in chain, got %v", err) + }) + } +} diff --git a/pkg/tapper/config.go b/pkg/tapper/config.go index 776bf82..06cd3b2 100644 --- a/pkg/tapper/config.go +++ b/pkg/tapper/config.go @@ -653,8 +653,8 @@ func (cfg *Config) AddKeg(alias string, target keg.Target) error { if cfg == nil { return fmt.Errorf("config is nil") } - if alias == "" { - return fmt.Errorf("alias is required") + if err := ValidateKegAlias(alias); err != nil { + return err } if cfg.data == nil { cfg.data = &configDTO{} diff --git a/pkg/tapper/keg_backend.go b/pkg/tapper/keg_backend.go new file mode 100644 index 0000000..1489653 --- /dev/null +++ b/pkg/tapper/keg_backend.go @@ -0,0 +1,49 @@ +package tapper + +import ( + "github.com/jlrickert/tapper/pkg/keg" +) + +// KegBackendLabel returns a stable, path-free identifier for a keg target +// suitable for user-facing output. It is used by surfaces that must describe +// "what kind of keg is this" without leaking the underlying filesystem path, +// remote URL, or other location-revealing details. +// +// Mapping by scheme: +// +// - file-backed: "file-backed" +// - hub: "hub:/@/" +// - http(s): "http" or "https" +// - in-memory: "in-memory" +// - other/unknown: the scheme string, or "" when target is nil +// +// The hub label re-applies the "@" sigil so the rendered string round-trips +// with the canonical hub shorthand the user originally typed. File-backed +// kegs intentionally collapse to a single token: the alias is the user- +// visible handle, and the path lives only behind `tap info`. +func KegBackendLabel(target *keg.Target) string { + if target == nil { + return "" + } + // Memory targets do not surface through Scheme() because NewMemory + // leaves every string field blank — Scheme() falls through to + // SchemeFile in that case. Check the explicit Memory flag first so + // in-memory kegs render correctly even before any persistence work. + if target.Memory { + return "in-memory" + } + switch target.Scheme() { + case keg.SchemeFile: + return "file-backed" + case keg.SchemeHub: + return target.Hub + ":@" + target.Namespace + "/" + target.KegName + case keg.SchemeMemory: + return "in-memory" + case keg.SchemeHTTP: + return "http" + case keg.SchemeHTTPs: + return "https" + default: + return target.Scheme() + } +} diff --git a/pkg/tapper/keg_backend_test.go b/pkg/tapper/keg_backend_test.go new file mode 100644 index 0000000..772ed51 --- /dev/null +++ b/pkg/tapper/keg_backend_test.go @@ -0,0 +1,44 @@ +package tapper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func TestKegBackendLabel(t *testing.T) { + t.Parallel() + + t.Run("nil_target_returns_empty", func(t *testing.T) { + t.Parallel() + require.Equal(t, "", tapper.KegBackendLabel(nil)) + }) + + t.Run("file_target_collapses_to_file_backed", func(t *testing.T) { + t.Parallel() + target := keg.NewFile("/home/testuser/Documents/kegs/notes") + require.Equal(t, "file-backed", tapper.KegBackendLabel(&target)) + }) + + t.Run("hub_target_renders_canonical_shorthand", func(t *testing.T) { + t.Parallel() + target := keg.NewApi("knut", "alice", "blog") + require.Equal(t, "knut:@alice/blog", tapper.KegBackendLabel(&target)) + }) + + t.Run("memory_target_collapses_to_in_memory", func(t *testing.T) { + t.Parallel() + target := keg.NewMemory("scratch") + require.Equal(t, "in-memory", tapper.KegBackendLabel(&target)) + }) + + t.Run("http_target_returns_scheme_only", func(t *testing.T) { + t.Parallel() + target, err := keg.Parse("https://example.com/kegs/blog") + require.NoError(t, err) + require.Equal(t, "https", tapper.KegBackendLabel(target)) + }) +} diff --git a/pkg/tapper/tap_dir.go b/pkg/tapper/tap_dir.go deleted file mode 100644 index a51a39b..0000000 --- a/pkg/tapper/tap_dir.go +++ /dev/null @@ -1,89 +0,0 @@ -package tapper - -import ( - "context" - "fmt" - "path/filepath" - "strings" - - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" -) - -type DirOptions struct { - KegTargetOptions - - NodeID string -} - -func (t *Tap) Dir(ctx context.Context, opts DirOptions) (string, error) { - k, err := t.resolveKeg(ctx, opts.KegTargetOptions) - if err != nil { - return "", fmt.Errorf("unable to open keg: %w", err) - } - if k.Target == nil { - return "", fmt.Errorf("keg target is not configured") - } - - if k.Target.Scheme() == keg.SchemeFile { - path := toolkit.ExpandEnv(t.Runtime, k.Target.File) - expanded, err := toolkit.ExpandPath(t.Runtime, path) - if err != nil { - return "", fmt.Errorf("unable to resolve keg directory: %w", err) - } - kegDir := filepath.Clean(expanded) - - if strings.TrimSpace(opts.NodeID) == "" { - return kegDir, nil - } - - id, err := parseNodeID(opts.NodeID) - if err != nil { - return "", err - } - - exists, err := t.nodeExistsWithContent(ctx, k, id) - if err != nil { - return "", fmt.Errorf("unable to check node existence: %w", err) - } - if !exists { - return "", fmt.Errorf("node %s not found", id.Path()) - } - - return filepath.Join(kegDir, id.Path()), nil - } - - if strings.TrimSpace(opts.NodeID) != "" { - return "", fmt.Errorf("node directory is only available for local file-backed kegs") - } - - return k.Target.Path(), nil -} - -// ListKegs returns available keg aliases from local discovery paths and config. -// When cache is true, cached config values may be used. -func (t *Tap) ListKegs(cache bool) ([]string, error) { - cfg, err := t.ConfigService.Config(cache) - if err != nil { - return nil, fmt.Errorf("failed to list kegs: %w", err) - } - var results []string - if localKegs, err := t.ConfigService.DiscoveredKegAliases(cache); err == nil { - results = append(results, localKegs...) - } - - results = append(results, cfg.ListKegs()...) - - // Extract unique directories containing keg files - kegDirs := make([]string, 0, len(results)) - seenDirs := make(map[string]bool) - for _, result := range results { - dir := firstDir(result) - if !seenDirs[dir] { - kegDirs = append(kegDirs, dir) - seenDirs[dir] = true - } - } - - return kegDirs, nil -} diff --git a/pkg/tapper/tap_index.go b/pkg/tapper/tap_index.go index 89fd3ae..25dd95d 100644 --- a/pkg/tapper/tap_index.go +++ b/pkg/tapper/tap_index.go @@ -63,6 +63,9 @@ func (t *Tap) Index(ctx context.Context, opts IndexOptions) (string, error) { return "", fmt.Errorf("unable to rebuild indices: %w", err) } - output := fmt.Sprintf("Indices rebuilt for %s\n", k.Target.Path()) - return output, nil + label := KegBackendLabel(k.Target) + if label == "" { + return "Indices rebuilt\n", nil + } + return fmt.Sprintf("Indices rebuilt (%s)\n", label), nil } diff --git a/pkg/tapper/tap_init.go b/pkg/tapper/tap_init.go index 49f6f28..26be4ab 100644 --- a/pkg/tapper/tap_init.go +++ b/pkg/tapper/tap_init.go @@ -2,11 +2,13 @@ package tapper import ( "context" + "errors" "fmt" "path/filepath" "strings" appCtx "github.com/jlrickert/cli-toolkit/appctx" + "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" ) @@ -25,6 +27,14 @@ type InitOptions struct { Creator string Title string Keg string + + // NonInteractive suppresses interactive prompts when set, forcing the + // caller to rely on flag-driven defaults (platform user-data dir, alias + // inferred from cwd, etc.) even when the surface is attached to a TTY. + // The Tap method itself does not consult this field — TTY handling is a + // CLI/MCP concern — but the option lives on InitOptions so both the CLI + // flag and the MCP input field map to the same canonical contract. + NonInteractive bool } func (o InitOptions) LocalDestination() bool { @@ -39,9 +49,10 @@ func (o InitOptions) LocalDestination() bool { // - hub: API target entry written to config only func (t *Tap) InitKeg(ctx context.Context, options InitOptions) (*keg.Target, error) { alias := strings.TrimSpace(options.Keg) - if alias == "" { - return nil, fmt.Errorf("keg alias is required: %w", keg.ErrInvalid) + if err := ValidateKegAlias(alias); err != nil { + return nil, err } + options.Keg = alias enabled := 0 if options.LocalDestination() { @@ -99,7 +110,7 @@ func (t *Tap) InitKeg(ctx context.Context, options InitOptions) (*keg.Target, er } projectPath, err = t.Runtime.ResolvePath(projectPath, false) if err != nil { - return nil, fmt.Errorf("unable to resolve project path %q: %w", options.Path, err) + return nil, fmt.Errorf("unable to resolve project path %q: %w", projectPath, err) } target, err = t.initProjectKeg(ctx, initLocalOptions{ Path: projectPath, @@ -151,7 +162,10 @@ func (t *Tap) initUserKeg(ctx context.Context, opts InitOptions) (*keg.Target, e } repoPath := cfg.PrimaryKegSearchPath() if repoPath == "" { - return nil, fmt.Errorf("kegSearchPaths not defined in user config (set via tap repo config --user): %w", keg.ErrNotExist) + repoPath, err = defaultUserKegRoot(t.Runtime) + if err != nil { + return nil, fmt.Errorf("kegSearchPaths not configured and platform default unavailable: %w", err) + } } kegPath := filepath.Join(repoPath, opts.Keg) @@ -177,7 +191,10 @@ func (t *Tap) initUserKeg(ctx context.Context, opts InitOptions) (*keg.Target, e if alias != "" { userCfg, err := t.ConfigService.UserConfig(false) if err != nil { - return nil, err + if !errors.Is(err, keg.ErrNotExist) { + return nil, err + } + userCfg = &Config{data: &configDTO{}} } if err := userCfg.AddKeg(alias, target); err != nil { return nil, err @@ -204,8 +221,8 @@ type initHubOptions struct { // initHub creates an API target and optionally stores it in user config. func (t *Tap) initHub(opts initHubOptions) (*keg.Target, error) { - if opts.Alias == "" { - return nil, fmt.Errorf("alias required: %w", keg.ErrInvalid) + if err := ValidateKegAlias(opts.Alias); err != nil { + return nil, err } // Determine hub name. Prefer explicit flag, then project config. @@ -246,7 +263,10 @@ func (t *Tap) initHub(opts initHubOptions) (*keg.Target, error) { if opts.AddUserConfig { userCfg, err := t.ConfigService.UserConfig(false) if err != nil { - return nil, err + if !errors.Is(err, keg.ErrNotExist) { + return nil, err + } + userCfg = &Config{data: &configDTO{}} } if err := userCfg.AddKeg(opts.Alias, target); err != nil { return nil, err @@ -259,3 +279,16 @@ func (t *Tap) initHub(opts initHubOptions) (*keg.Target, error) { return &target, nil } + +// defaultUserKegRoot returns the platform-default directory under which user +// kegs are created when no kegSearchPaths is configured. Linux/macOS resolve +// to /tapper/kegs; Windows resolves to +// %LOCALAPPDATA%\data\tapper\kegs. Resolution flows through the cli-toolkit +// runtime so sandboxed tests get the same answer as production. +func defaultUserKegRoot(rt *toolkit.Runtime) (string, error) { + dataDir, err := toolkit.UserDataPath(rt) + if err != nil { + return "", fmt.Errorf("resolve user data dir: %w", err) + } + return filepath.Join(dataDir, "tapper", "kegs"), nil +} diff --git a/pkg/tapper/tap_list_kegs.go b/pkg/tapper/tap_list_kegs.go new file mode 100644 index 0000000..7162705 --- /dev/null +++ b/pkg/tapper/tap_list_kegs.go @@ -0,0 +1,30 @@ +package tapper + +import "fmt" + +// ListKegs returns available keg aliases from local discovery paths and config. +// When cache is true, cached config values may be used. +func (t *Tap) ListKegs(cache bool) ([]string, error) { + cfg, err := t.ConfigService.Config(cache) + if err != nil { + return nil, fmt.Errorf("failed to list kegs: %w", err) + } + var results []string + if localKegs, err := t.ConfigService.DiscoveredKegAliases(cache); err == nil { + results = append(results, localKegs...) + } + + results = append(results, cfg.ListKegs()...) + + kegDirs := make([]string, 0, len(results)) + seenDirs := make(map[string]bool) + for _, result := range results { + dir := firstDir(result) + if !seenDirs[dir] { + kegDirs = append(kegDirs, dir) + seenDirs[dir] = true + } + } + + return kegDirs, nil +} diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index 5ecfcbb..5e71c4c 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -4,14 +4,11 @@ import ( "context" "fmt" "io/fs" - "path/filepath" "sort" "strconv" "strings" - "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/integrations" - "github.com/jlrickert/tapper/pkg/keg" ) // OrientTierMin / OrientTierMax are the valid tier bounds for Tap.Orient. @@ -112,19 +109,25 @@ func (t *Tap) Orient(ctx context.Context, opts OrientOptions) (string, error) { } // activeKegLabel is the structured outcome of orient's active-keg -// resolution. Exposing alias and target separately lets the renderer -// format them differently (e.g. "alias → target", "target (no alias)", -// or the "(none configured)" fallback) without re-deriving from a -// pre-formatted string. +// resolution. Exposing alias and backend separately lets the renderer +// format them differently (e.g. "alias (backend)", "(backend) (no +// alias)", or the "(none configured)" fallback) without re-deriving +// from a pre-formatted string. +// +// Backend deliberately omits the filesystem path or remote URL: orient +// is the description surface, not the locator. `tap info` is the +// dedicated "where is this keg" command and is allowed to surface +// paths; orient stays path-free so that tier-0 output is portable +// across machines and safe to paste into transcripts. type activeKegLabel struct { // Alias is the configured alias for the resolved keg, or "" when // the resolution succeeded but no alias matches the target (e.g. an // ad-hoc cwd keg that is not registered in tap config). Alias string - // Target is a human-readable target reference: a tilde-anchored - // filesystem path for file kegs, the URL string otherwise. Empty - // when no keg resolved. - Target string + // Backend is a path-free identifier describing the storage backend + // (e.g. "file-backed", "knut:@alice/blog", "in-memory"). Empty when + // no keg resolved. + Backend string // Unresolved is true when KegService could not find any keg for the // current selection (no kegs configured, no matching alias, etc.). // Renderers surface a directed hint instead of a path. @@ -152,43 +155,13 @@ func (t *Tap) resolveActiveKegLabel(ctx context.Context, opts KegTargetOptions) return activeKegLabel{Unresolved: true} } - label := activeKegLabel{Target: kegTargetDisplay(t.Runtime, k.Target)} + label := activeKegLabel{Backend: KegBackendLabel(k.Target)} if cfg, _ := t.KegService.ConfigService.Config(true); cfg != nil { label.Alias = cfg.LookupAliasForTarget(t.Runtime, k.Target.String()) } return label } -// kegTargetDisplay renders a keg.Target for the active-keg line. File -// targets show a tilde-anchored path when the resolved location lives -// under the user's home; everything else falls back to the canonical -// target string. Keeps the orient output stable across machines without -// hiding the path's location entirely. -func kegTargetDisplay(rt *toolkit.Runtime, target *keg.Target) string { - if target == nil { - return "" - } - raw := target.String() - if target.Scheme() != keg.SchemeFile || rt == nil { - return raw - } - path := toolkit.ExpandEnv(rt, target.Path()) - if expanded, err := toolkit.ExpandPath(rt, path); err == nil { - path = expanded - } - path = filepath.Clean(path) - if home, err := rt.GetHome(); err == nil && home != "" { - cleanHome := filepath.Clean(home) - if rel, err := filepath.Rel(cleanHome, path); err == nil && !strings.HasPrefix(rel, "..") { - if rel == "." { - return "~" - } - return "~/" + filepath.ToSlash(rel) - } - } - return path -} - // buildOrientPayload assembles the orient bytes at tier for the given // host, active-keg label, manifest-keg, and flight. Exposed to other // packages via Tap.Orient. @@ -296,27 +269,29 @@ func buildOrientPayload(host string, active activeKegLabel, manifestKeg, flight // formatActiveKegLine renders the right-hand side of the "Active keg:" // line for tier 0. Three shapes: // -// Unresolved: "(none configured; run `tap repo init` to register one)" -// Alias + target: "`alias` → ~/path/to/keg" -// Target only: "~/path/to/keg (no alias)" +// Unresolved: "(none configured; run `tap init` to register one)" +// Alias + backend: "`alias` (file-backed)" +// Backend only: "(file-backed; no alias)" // // The directed hint on the unresolved branch matches the surface area // users land on when they bootstrap tapper for the first time, so it -// names the next concrete step instead of saying nothing. +// names the next concrete step instead of saying nothing. Backend is a +// path-free identifier (see KegBackendLabel) so orient output never +// reveals filesystem location — `tap info` is the dedicated locator. func formatActiveKegLine(active activeKegLabel) string { if active.Unresolved { - return "(none configured; run `tap repo init` to register one)" + return "(none configured; run `tap init` to register one)" } if active.Alias != "" { - if active.Target == "" { + if active.Backend == "" { return "`" + active.Alias + "`" } - return "`" + active.Alias + "` → " + active.Target + return "`" + active.Alias + "` (" + active.Backend + ")" } - if active.Target != "" { - return active.Target + " (no alias)" + if active.Backend != "" { + return "(" + active.Backend + "; no alias)" } - return "(none configured; run `tap repo init` to register one)" + return "(none configured; run `tap init` to register one)" } // appendCanonical reads integrations/content/ from the embedded diff --git a/pkg/tapper/tap_orient_test.go b/pkg/tapper/tap_orient_test.go index 3f998f9..be9725b 100644 --- a/pkg/tapper/tap_orient_test.go +++ b/pkg/tapper/tap_orient_test.go @@ -111,7 +111,7 @@ func TestTap_Orient_FlightAtTier0IsIgnored(t *testing.T) { // TestTap_Orient_ActiveKeg_NoneConfigured covers the bootstrap case: // a fresh sandbox with no kegs anywhere on disk. The active-keg line // must surface a directed hint that names the next concrete step -// (`tap repo init`) instead of the previous "(auto-detect from working +// (`tap init`) instead of the previous "(auto-detect from working // directory)" placeholder, which described mechanism without telling // the user how to advance. func TestTap_Orient_ActiveKeg_NoneConfigured(t *testing.T) { @@ -119,15 +119,15 @@ func TestTap_Orient_ActiveKeg_NoneConfigured(t *testing.T) { tap := newOrientTap(t) payload, err := tap.Orient(context.Background(), tapper.OrientOptions{Tier: 0}) require.NoError(t, err) - require.Contains(t, payload, "Active keg: (none configured; run `tap repo init` to register one)") + require.Contains(t, payload, "Active keg: (none configured; run `tap init` to register one)") require.NotContains(t, payload, "auto-detect from working directory") } // TestTap_Orient_ActiveKeg_AliasResolutionFromCwd covers the common // case: a registered alias whose path matches the working directory. -// Resolution should surface the alias plus a tilde-anchored path so -// the user sees both the symbol the rest of the CLI uses and the -// concrete location the next operation hits. +// Resolution should surface the alias plus a path-free backend label so +// the user sees the symbol the rest of the CLI uses without leaking the +// underlying filesystem location into the description surface. func TestTap_Orient_ActiveKeg_AliasResolutionFromCwd(t *testing.T) { t.Parallel() fx := NewSandbox(t) @@ -151,16 +151,18 @@ kegs: payload, err := tap.Orient(context.Background(), tapper.OrientOptions{Tier: 0}) require.NoError(t, err) - require.Contains(t, payload, "Active keg: `notes` → ~/Documents/kegs/notes") + require.Contains(t, payload, "Active keg: `notes` (file-backed)") + require.NotContains(t, payload, "Documents/kegs/notes") } // TestTap_Orient_ActiveKeg_NoAliasFallback covers a project-local keg // resolved from the working directory but not registered under any // alias in tap config — the same shape the `keg` CLI hits via its // ForceProjectResolution profile, and what `tap orient --project` -// produces. The active-keg line surfaces the path with a "(no alias)" -// suffix so the user knows the keg works without `--keg` but cannot be -// referenced by name elsewhere. +// produces. The active-keg line surfaces the backend label with a +// "; no alias" suffix so the user knows the keg works without `--keg` +// but cannot be referenced by name elsewhere. The filesystem path is +// intentionally omitted — `tap info` is the dedicated locator. func TestTap_Orient_ActiveKeg_NoAliasFallback(t *testing.T) { t.Parallel() fx := NewSandbox(t) @@ -179,8 +181,8 @@ func TestTap_Orient_ActiveKeg_NoAliasFallback(t *testing.T) { }) require.NoError(t, err) require.Contains(t, payload, "Active keg:") - require.Contains(t, payload, "(no alias)") - require.Contains(t, payload, "~/loose/kegs/loose") + require.Contains(t, payload, "(file-backed; no alias)") + require.NotContains(t, payload, "loose/kegs/loose") } // TestTap_Orient_ActiveKeg_ExplicitOverride confirms that an explicit @@ -213,7 +215,8 @@ func TestTap_Orient_ActiveKeg_ExplicitOverride(t *testing.T) { KegTargetOptions: tapper.KegTargetOptions{Keg: "archive"}, }) require.NoError(t, err) - require.Contains(t, payload, "Active keg: `archive` → ~/Documents/kegs/archive") + require.Contains(t, payload, "Active keg: `archive` (file-backed)") + require.NotContains(t, payload, "Documents/kegs/archive") require.NotContains(t, payload, "notes") } diff --git a/schemas/keg-config.json b/schemas/keg-config.json index 5c8a63b..766e7bf 100644 --- a/schemas/keg-config.json +++ b/schemas/keg-config.json @@ -8,9 +8,7 @@ "kegv": { "type": "string", "description": "Configuration schema version.", - "enum": [ - "2025-07" - ] + "enum": ["2025-07"] }, "updated": { "type": "string", @@ -53,10 +51,7 @@ "description": "Destination URL or keg target." } }, - "required": [ - "alias", - "url" - ], + "required": ["alias", "url"], "additionalProperties": true } }, @@ -69,7 +64,7 @@ "properties": { "file": { "type": "string", - "description": "Path to the generated index file relative to the keg root." + "description": "Filename of the generated index artifact. The canonical form is the bare filename (e.g. \"backlinks\", \"concepts.md\"); the on-disk path is always under the keg's dex/ directory and the prefix is applied at write time. A legacy \"dex/\"-prefixed value is accepted at parse and normalized to the bare form." }, "summary": { "type": "string", @@ -91,10 +86,7 @@ "default": "updated" } }, - "required": [ - "file", - "summary" - ], + "required": ["file", "summary"], "additionalProperties": true } }, @@ -115,9 +107,7 @@ "description": "Short description of the entity." } }, - "required": [ - "id" - ], + "required": ["id"], "additionalProperties": false } }, @@ -178,8 +168,6 @@ "additionalProperties": false } }, - "required": [ - "kegv" - ], + "required": ["kegv"], "additionalProperties": true } diff --git a/test-env/Dockerfile b/test-env/Dockerfile index a70b717..da8f654 100644 --- a/test-env/Dockerfile +++ b/test-env/Dockerfile @@ -87,9 +87,22 @@ ENV GOPATH=/home/jlrickert/go \ ARG DOTS_REV=81f31990bbae1bc5e0dddf337f351feab43bbf87 RUN go install "github.com/jlrickert/dots/cmd/dots@${DOTS_REV}" -RUN dots init --from https://github.com/jlrickert/dotfiles.git \ - --path dots-config \ - --name jlrickert +# DOTFILES_REV pins the dotfiles checkout. dots init has no --ref flag, so +# embedding the SHA in the layer command is what makes Docker invalidate +# and re-clone when this changes. Bump manually here, or use +# `task sandbox:refresh-dotfiles` to rebuild against the live HEAD. +ARG DOTFILES_REV=eef53375d9aaac923c1aded0bfcd648bf9d24558 +RUN echo "dotfiles@${DOTFILES_REV}" \ + && dots init --from https://github.com/jlrickert/dotfiles.git \ + --path dots-config \ + --name jlrickert + +# Install personal dotfile packages (common-shell pulls in transitively as a +# requires of zsh). SHELL=/bin/bash works around a dots bug where post_install +# hooks are invoked under POSIX sh; the zsh package's hook needs bash. Drop +# the prefix once dots is updated. dots install takes one package per call. +RUN SHELL=/bin/bash dots install jlrickert/zsh \ + && SHELL=/bin/bash dots install jlrickert/zellij # Pre-create directories that the docker-compose volumes will mount over, so # the first boot does not race on directory creation with the bind mounts. @@ -97,6 +110,14 @@ RUN mkdir -p /home/jlrickert/go \ /home/jlrickert/.cache/go-build \ /home/jlrickert/.local/state/tapper +# zsh's default fpath includes /usr/local/share/zsh/site-functions; chown it +# to jlrickert so entrypoint.sh can drop Cobra-generated _tap / _keg files +# there at first boot without escalating. This keeps the dotfiles' zshrc out +# of the completion-wiring loop entirely. +USER root +RUN chown jlrickert:jlrickert /usr/local/share/zsh/site-functions +USER jlrickert + # Install the entrypoint. We briefly flip to root to COPY into /usr/local/bin # and set the executable bit, then restore the unprivileged user so the # container runtime defaults to jlrickert. diff --git a/test-env/README.md b/test-env/README.md index d1049fe..55fa0ea 100644 --- a/test-env/README.md +++ b/test-env/README.md @@ -2,10 +2,15 @@ ## Purpose -An isolated Ubuntu 24.04 container preloaded with the Go toolchain, common dev -tooling, and the user's `dots`-bootstrapped dotfiles. The tapper repo is bind -mounted at `/workspace/tapper` so host edits appear live inside the container, -while Go module and build caches live in named volumes for speed. +An isolated Ubuntu 24.04 container for testing tapper as a real user would +encounter it. The tapper source is bind-mounted **read-only** at +`/usr/local/src/tapper` purely as a build input -- the interactive shell +lands in the user's home (`/home/jlrickert`) with no project context, so +`tap init` and friends behave the same as on a vanilla machine. + +Go module and build caches live in named volumes for speed; user-created +kegs live in the container's writable layer so they survive `restart` and +shell re-entry but are wiped on `rebuild` / `clean`. ## Prerequisites @@ -22,28 +27,31 @@ task sandbox:shell # drop into an interactive zsh login shell ## Task reference -| Task | What it does | -| -------------------------- | --------------------------------------------------------- | -| `task sandbox:build` | Build or refresh the sandbox image. | -| `task sandbox:up` | Start the container in the background. | -| `task sandbox:down` | Stop and remove the container (keeps named volumes). | -| `task sandbox:restart` | Bounce the running container. | -| `task sandbox:rebuild` | Rebuild the image and recreate the container. | -| `task sandbox:shell` | Interactive zsh login shell inside the container. | -| `task sandbox:exec` | Run an arbitrary command (`task sandbox:exec -- ls -la`). | -| `task sandbox:logs` | Follow container logs. | -| `task sandbox:status` | Show container state. | -| `task sandbox:rebuild-tap` | Reinstall `tap` and `keg` from the bind-mounted source. | -| `task sandbox:test` | Run `go test ./...` inside the container. | -| `task sandbox:clean` | Remove container AND named volumes (nukes caches). | +| Task | What it does | +| ------------------------------- | --------------------------------------------------------- | +| `task sandbox:build` | Build or refresh the sandbox image. | +| `task sandbox:up` | Start the container in the background. | +| `task sandbox:down` | Stop and remove the container (keeps named volumes). | +| `task sandbox:restart` | Bounce the running container. | +| `task sandbox:rebuild` | Rebuild the image and recreate the container. | +| `task sandbox:shell` | Interactive zsh login shell inside the container. | +| `task sandbox:exec` | Run an arbitrary command (`task sandbox:exec -- ls -la`). | +| `task sandbox:logs` | Follow container logs. | +| `task sandbox:status` | Show container state. | +| `task sandbox:rebuild-tap` | Reinstall `tap` and `keg` from the bind-mounted source. | +| `task sandbox:refresh-dotfiles` | Rebuild image against live dotfiles HEAD; recreate. | +| `task sandbox:populate` | Seed the sandbox with a fixture keg (`-- `). | +| `task sandbox:test` | Run `go test ./...` inside the container. | +| `task sandbox:clean` | Remove container AND named volumes (nukes caches). | ## Work mode (local cli-toolkit) The default `sandbox` mirrors CI: builds use the `go.mod`-pinned cli-toolkit release. To iterate against a local cli-toolkit working tree instead, use the parallel `sandbox-work` service. It bind-mounts the cli-toolkit checkout -at `/workspace/cli-toolkit` and leaves `GOWORK` unset so the host's `go.work` -(referencing `../cli-toolkit`) is honored inside the container. +at `/usr/local/src/cli-toolkit` (read-only) and leaves `GOWORK` unset so the +host's `go.work` (referencing `../cli-toolkit`) is honored inside the +container. | Task | What it does | | ------------------------------- | --------------------------------------------------------------- | @@ -65,21 +73,71 @@ Prerequisites: ## Inside the container -- Repo: `/workspace/tapper` (bind mount, read-write, host edits appear live). -- Dotfiles: `~/dots-config` with `dots` already initialized. +- Working directory at shell start: `/home/jlrickert` (the user's home). +- Source (build input only): `/usr/local/src/tapper` (read-only bind mount). + Host edits appear live; the sandbox cannot mutate the host tree. - `GOPATH=/home/jlrickert/go`, `GOCACHE=/home/jlrickert/.cache/go-build` (both backed by named volumes). - Tapper state: `~/.local/state/tapper` (named volume). -- `tap` and `keg` are on `$PATH` after the first boot. +- User-created kegs default to `~/.local/share/tapper/kegs//` (in + the container's writable layer; persists through `restart`, wiped on + `rebuild`). +- `tap` and `keg` are on `$PATH` after the first boot, with zsh tab + completion for both registered automatically (entrypoint drops + Cobra-generated `_tap` and `_keg` files into + `/usr/local/share/zsh/site-functions/`, which is in zsh's default + fpath). The dir is `chown`d to `jlrickert` in the Dockerfile so the + unprivileged user can write there. +- Personal dotfiles packages installed: `common-shell` (transitive), + `zsh`, `zellij`. Source lives at + `/home/jlrickert/.local/state/dots/taps/jlrickert/`. To add or remove + packages, edit the `dots install` line in `test-env/Dockerfile` and + `task sandbox:rebuild`. + +## Fixtures + +`test-env/fixtures/` holds named keg trees that can be loaded into a +running sandbox: + +```sh +task sandbox:populate -- --list # show available fixtures +task sandbox:populate -- minimal # copy 'minimal' into ~/.local/share/tapper/kegs/ +task sandbox:populate -- --all # copy every fixture +``` + +The first populate also writes a minimal `~/.config/tapper/config.yaml` +with the fixture root in `kegSearchPaths` so `tap list-kegs` discovers +them. If you've already run `tap init`, the script leaves your config +alone and prints a hint. + +To add a fixture, create `test-env/fixtures//` with a `keg` config +file and a `0/README.md`. See `test-env/fixtures/README.md`. + +## Updating dotfiles + +The Dockerfile pins the dotfiles checkout via `ARG DOTFILES_REV=` so +layer caching is deterministic. Two ways to refresh: + +- **Track HEAD live:** `task sandbox:refresh-dotfiles` resolves + `git@github.com/jlrickert/dotfiles HEAD`, rebuilds the image with that + SHA as `DOTFILES_REV`, and recreates the container. No Dockerfile edit. +- **Pin a specific SHA:** edit `DOTFILES_REV` in `test-env/Dockerfile` to + the SHA you want, then `task sandbox:rebuild`. Use this when committing + a known-good dotfiles version alongside other sandbox changes. + +The same shape applies to `DOTS_REV` (the `dots` binary itself), bumped +manually when a newer release lands. ## Caveats - First `task sandbox:up` is slow: downloads Ubuntu base, installs Go, runs `dots init`, and performs the initial `go install ./cmd/tap ./cmd/keg`. -- Host edits appear live under `/workspace/tapper`, but rebuilt binaries only - land on `$PATH` after `task sandbox:rebuild-tap`. +- Host edits appear live under `/usr/local/src/tapper`, but rebuilt + binaries only land on `$PATH` after `task sandbox:rebuild-tap`. - `task sandbox:clean` destroys the Go module cache, build cache, and tapper state; expect the next `up` to behave like a first boot. +- The source bind is read-only -- write inside the container ends up in + the container's writable layer, not on the host. Edit on the host. ## Troubleshooting diff --git a/test-env/Taskfile.yml b/test-env/Taskfile.yml index ee32565..bd014b2 100644 --- a/test-env/Taskfile.yml +++ b/test-env/Taskfile.yml @@ -53,12 +53,40 @@ tasks: rebuild-tap: desc: Rebuild tap and keg binaries inside the sandbox from current source. cmds: - - docker compose exec sandbox bash -lc 'cd /workspace/tapper && go install ./cmd/tap ./cmd/keg' + - | + docker compose exec sandbox bash -lc ' + set -e + cd /usr/local/src/tapper && go install ./cmd/tap ./cmd/keg + tap completion zsh > /usr/local/share/zsh/site-functions/_tap + keg completion zsh > /usr/local/share/zsh/site-functions/_keg + ' + + refresh-dotfiles: + desc: | + Rebuild the sandbox image with the current dotfiles HEAD, then + recreate the container. Use after pushing to dotfiles when you + do not want to bump DOTFILES_REV in the Dockerfile by hand. + vars: + DOTFILES_HEAD: + sh: git ls-remote https://github.com/jlrickert/dotfiles.git HEAD | cut -f1 + cmds: + - docker compose build --build-arg DOTFILES_REV={{.DOTFILES_HEAD}} sandbox + - docker compose up -d --force-recreate sandbox + + populate: + desc: | + Seed the sandbox with a keg fixture from test-env/fixtures/. + Examples: + task sandbox:populate -- --list + task sandbox:populate -- minimal + task sandbox:populate -- --all + cmds: + - docker compose exec sandbox bash /usr/local/src/tapper/test-env/scripts/populate.sh {{.CLI_ARGS}} test: desc: Run the Go test suite inside the sandbox. cmds: - - docker compose exec sandbox bash -lc 'cd /workspace/tapper && go test ./...' + - docker compose exec sandbox bash -lc 'cd /usr/local/src/tapper && go test ./...' clean: desc: Remove the container AND named volumes (nukes caches and state). @@ -97,12 +125,18 @@ tasks: test-work: desc: Run the Go test suite inside the work-mode sandbox. cmds: - - "{{.WORK_COMPOSE}} exec sandbox-work bash -lc 'cd /workspace/tapper && go test ./...'" + - "{{.WORK_COMPOSE}} exec sandbox-work bash -lc 'cd /usr/local/src/tapper && go test ./...'" rebuild-tap-work: desc: Rebuild tap and keg in work mode (links local cli-toolkit). cmds: - - "{{.WORK_COMPOSE}} exec sandbox-work bash -lc 'cd /workspace/tapper && go install ./cmd/tap ./cmd/keg'" + - | + {{.WORK_COMPOSE}} exec sandbox-work bash -lc ' + set -e + cd /usr/local/src/tapper && go install ./cmd/tap ./cmd/keg + tap completion zsh > /usr/local/share/zsh/site-functions/_tap + keg completion zsh > /usr/local/share/zsh/site-functions/_keg + ' _ensure-go-work: desc: Bootstrap ../go.work referencing ../cli-toolkit if missing. diff --git a/test-env/docker-compose.work.yml b/test-env/docker-compose.work.yml index 300a591..a6ac8d8 100644 --- a/test-env/docker-compose.work.yml +++ b/test-env/docker-compose.work.yml @@ -1,8 +1,8 @@ # Work-mode overlay for the tapper sandbox. # # Defines a parallel `sandbox-work` service that bind-mounts the local -# cli-toolkit working tree at /workspace/cli-toolkit alongside tapper at -# /workspace/tapper, and leaves GOWORK unset so the host's go.work +# cli-toolkit working tree at /usr/local/src/cli-toolkit alongside tapper +# at /usr/local/src/tapper, and leaves GOWORK unset so the host's go.work # (which references ../cli-toolkit) is honored. Builds and tests run # inside the container then resolve cli-toolkit to the local working # tree instead of the go.mod-pinned version. @@ -29,17 +29,16 @@ services: tty: true stdin_open: true restart: unless-stopped - working_dir: /workspace/tapper command: ["sleep", "infinity"] environment: GOPATH: /home/jlrickert/go GOCACHE: /home/jlrickert/.cache/go-build # Empty (set, but no value) so entrypoint's `${GOWORK-off}` fallback - # does not kick in and Go auto-detects /workspace/tapper/go.work. + # does not kick in and Go auto-detects /usr/local/src/tapper/go.work. GOWORK: "" volumes: - - ../:/workspace/tapper:cached - - ${CLI_TOOLKIT_PATH:-../../cli-toolkit}:/workspace/cli-toolkit:cached + - ../:/usr/local/src/tapper:cached,ro + - ${CLI_TOOLKIT_PATH:-../../cli-toolkit}:/usr/local/src/cli-toolkit:cached,ro - tapper-go-mod:/home/jlrickert/go - tapper-go-build:/home/jlrickert/.cache/go-build - tapper-state:/home/jlrickert/.local/state/tapper diff --git a/test-env/docker-compose.yml b/test-env/docker-compose.yml index ebf3b21..b0c3929 100644 --- a/test-env/docker-compose.yml +++ b/test-env/docker-compose.yml @@ -12,7 +12,6 @@ services: tty: true stdin_open: true restart: unless-stopped - working_dir: /workspace/tapper command: ["sleep", "infinity"] environment: GOPATH: /home/jlrickert/go @@ -20,7 +19,12 @@ services: # Ignore go.work; use go.mod-pinned module versions like CI does. GOWORK: "off" volumes: - - ../:/workspace/tapper:cached + # Source is a build input only -- mounted read-only so the sandbox + # cannot mutate the host tree, and out of /workspace so the user's + # home is the natural cwd. Kegs created at runtime live in the + # container's writable layer (~/.local/share/tapper/kegs); they + # survive `restart` but are wiped by `rebuild` / `clean`. + - ../:/usr/local/src/tapper:cached,ro - tapper-go-mod:/home/jlrickert/go - tapper-go-build:/home/jlrickert/.cache/go-build - tapper-state:/home/jlrickert/.local/state/tapper diff --git a/test-env/entrypoint.sh b/test-env/entrypoint.sh index fd1a629..991bc7e 100755 --- a/test-env/entrypoint.sh +++ b/test-env/entrypoint.sh @@ -6,7 +6,7 @@ set -euo pipefail SENTINEL="${HOME}/.sandbox-initialized" -REPO="/workspace/tapper" +REPO="/usr/local/src/tapper" # Disable go.work so the in-container build uses the pinned cli-toolkit # module version from go.mod instead of chasing ../cli-toolkit out of the @@ -20,8 +20,17 @@ export GOWORK="${GOWORK-off}" if [[ ! -f "${SENTINEL}" && -d "${REPO}" ]]; then echo "[sandbox] First boot: installing tap and keg from ${REPO}..." (cd "${REPO}" && go install ./cmd/tap ./cmd/keg) + + # Drop Cobra-generated completion files into the system zsh site-functions + # dir (already in default fpath, chown'd to jlrickert in the Dockerfile). + # task sandbox:rebuild-tap mirrors this regeneration step for binary + # updates after first boot. + COMPDIR="/usr/local/share/zsh/site-functions" + "${HOME}/go/bin/tap" completion zsh > "${COMPDIR}/_tap" + "${HOME}/go/bin/keg" completion zsh > "${COMPDIR}/_keg" + touch "${SENTINEL}" - echo "[sandbox] Ready. tap and keg are on PATH." + echo "[sandbox] Ready. tap and keg are on PATH; completion installed." fi exec "$@" diff --git a/test-env/fixtures/README.md b/test-env/fixtures/README.md new file mode 100644 index 0000000..2b17c14 --- /dev/null +++ b/test-env/fixtures/README.md @@ -0,0 +1,19 @@ +# Sandbox keg fixtures + +Each subdirectory here is a complete keg tree that can be copied into a +running sandbox via `task sandbox:populate -- `. Fixtures land at +`~/.local/share/tapper/kegs//` inside the container. + +## Adding a fixture + +1. Create a directory under `test-env/fixtures//`. +2. Populate it with a valid keg tree: + - `keg` config file (kegv: 2023-01) + - `0/README.md` for the zero node + - additional numbered nodes as desired +3. Run `task sandbox:populate -- --list` to confirm it appears. + +The fixture format mirrors the on-disk keg layout. Tapper auto-derives +`meta.yaml` and `stats.json` for nodes that don't include them, so a +hand-authored fixture only strictly needs the `keg` file plus +`/README.md` per node. diff --git a/test-env/fixtures/minimal/0/README.md b/test-env/fixtures/minimal/0/README.md new file mode 100644 index 0000000..8eb9297 --- /dev/null +++ b/test-env/fixtures/minimal/0/README.md @@ -0,0 +1,8 @@ +# Sorry, planned but not yet available + +This is the zero node. Tapper uses node 0 as the placeholder for content +that has been promised by another node but has not yet been written. + +Replace this content with whatever your keg's "missing target" page should +say. The zero node always exists; other nodes can link here when they +need to point at something that doesn't exist yet. diff --git a/test-env/fixtures/minimal/keg b/test-env/fixtures/minimal/keg new file mode 100644 index 0000000..574a906 --- /dev/null +++ b/test-env/fixtures/minimal/keg @@ -0,0 +1,14 @@ +updated: 2026-04-29 00:00:00Z +kegv: 2023-01 +title: Minimal sandbox fixture +creator: tapper sandbox +state: living +summary: | + A minimal keg fixture with a single zero node. Useful for exercising + list / cat / orient / doctor against a non-empty keg without the noise + of a fully populated example. +indexes: + - file: dex/changes.md + summary: latest changes + - file: dex/nodes.tsv + summary: all nodes by id diff --git a/test-env/scripts/populate.sh b/test-env/scripts/populate.sh new file mode 100755 index 0000000..bfc09aa --- /dev/null +++ b/test-env/scripts/populate.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Seed the sandbox with a keg fixture from test-env/fixtures/. +# +# Fixtures are copied into ~/.local/share/tapper/kegs// inside the +# sandbox container. A minimal user config at ~/.config/tapper/config.yaml +# is created if absent so kegSearchPaths includes the fixture root and +# `tap list-kegs` discovers them. + +set -euo pipefail + +FIXTURE_DIR=/usr/local/src/tapper/test-env/fixtures +KEG_ROOT="${HOME}/.local/share/tapper/kegs" +CFG_DIR="${HOME}/.config/tapper" +CFG_FILE="${CFG_DIR}/config.yaml" + +list_fixtures() { + if [[ ! -d "${FIXTURE_DIR}" ]]; then + echo "(no fixtures directory at ${FIXTURE_DIR})" + return + fi + local found=0 + for f in "${FIXTURE_DIR}"/*/; do + [[ -d "${f}" ]] || continue + echo " $(basename "${f}")" + found=1 + done + if [[ "${found}" -eq 0 ]]; then + echo " (none)" + fi +} + +usage() { + cat < + +Available fixtures: +$(list_fixtures) +USAGE +} + +# A user that ran 'tap init' has their own config; appending may produce a +# duplicate kegSearchPaths key. Only seed config when absent. If present, +# emit a one-line hint about the path the user can add manually. +ensure_config() { + if [[ -f "${CFG_FILE}" ]]; then + if ! grep -q "share/tapper/kegs" "${CFG_FILE}"; then + echo "[hint] ${CFG_FILE} exists; add '${KEG_ROOT}' to kegSearchPaths to discover fixtures." >&2 + fi + return + fi + mkdir -p "${CFG_DIR}" + cat > "${CFG_FILE}" <&2 + echo "Available:" >&2 + list_fixtures >&2 + return 1 + fi + if [[ -d "${dst}" ]]; then + echo "[skip] ${name} already at ${dst}" + return 0 + fi + mkdir -p "${KEG_ROOT}" + cp -r "${src}" "${dst}" + chmod -R u+w "${dst}" + echo "[ok] ${name} -> ${dst}" +} + +populate_all() { + if [[ ! -d "${FIXTURE_DIR}" ]]; then + echo "No fixtures directory at ${FIXTURE_DIR}" >&2 + return 1 + fi + local any=0 + for f in "${FIXTURE_DIR}"/*/; do + [[ -d "${f}" ]] || continue + populate_one "$(basename "${f}")" || true + any=1 + done + if [[ "${any}" -eq 0 ]]; then + echo "No fixtures to populate." + fi +} + +case "${1:-}" in + "" | -h | --help | --list) + usage + ;; + --all) + ensure_config + populate_all + ;; + *) + ensure_config + populate_one "$1" + ;; +esac