From b8b2f49755fdcdb40361d5b8570799cba5ab540b Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Tue, 28 Apr 2026 21:48:07 -0500 Subject: [PATCH 1/5] feat(config): canonicalize index file paths to bare form ParseKegConfig and applyDefaults strip a leading "dex/" from each IndexEntry.File so the in-memory struct holds the bare filename. The on-disk path is always under the keg's dex/ directory; the prefix is applied at write time. Legacy "dex/"-prefixed YAML continues to parse and is normalized to the bare form. NewConfig defaults emit bare names. coreIndexNames is keyed by bare filenames, and IsCoreIndex strips the prefix before lookup so both forms are recognized as core. The defensive TrimPrefix in WithConfig remains as safety for inline-constructed entries. --- pkg/keg/dex.go | 9 ++-- pkg/keg/dex_changes.go | 25 +++++------ pkg/keg/dex_test.go | 42 +++++++++++++++++++ pkg/keg/indexes.go | 4 +- pkg/keg/keg_config.go | 37 ++++++++++++++--- pkg/keg/keg_config_test.go | 85 ++++++++++++++++++++++++++++++++++++++ schemas/keg-config.json | 24 +++-------- 7 files changed, 186 insertions(+), 40 deletions(-) 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/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 } From ab99a60c476f564a179c1611232432e04e9aeb76 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Tue, 28 Apr 2026 18:32:19 -0500 Subject: [PATCH 2/5] feat(cli): promote tap repo init to top-level tap init Move the keg-init command to the top level so a new user types `tap init` rather than the buried `tap repo init`. Single-user codebase, no deprecation alias retained. - Rename pkg/cli/cmd_repo_init.go -> cmd_init.go and cmd_repo_init_test.go -> cmd_init_test.go. - Register init at the root under IncludeRepoCommand (preserves KegProfile parity, which never exposed init). - Apply filterRepoTargetFlagsInHelp to init so the inherited keg-target persistent flags don't double-print in help output. - Update tap_orient bootstrap hint and parity coverage map; MCP tool name remains repo_init for backward compatibility. - Update tests and docs that previously referenced tap repo init. --- README.md | 4 ++-- docs/README.md | 2 +- docs/architecture/testing-architecture.md | 2 +- .../domain-separation-and-migration.md | 2 +- docs/keg-structure/example-structures.md | 2 +- pkg/cli/cmd_cat_test.go | 4 ++-- pkg/cli/cmd_graph_test.go | 2 +- pkg/cli/cmd_index_test.go | 2 +- pkg/cli/cmd_info_test.go | 2 +- pkg/cli/{cmd_repo_init.go => cmd_init.go} | 24 +++++++++---------- ...cmd_repo_init_test.go => cmd_init_test.go} | 24 +++++++++---------- pkg/cli/cmd_repo.go | 1 - pkg/cli/cmd_repo_config_test.go | 2 +- pkg/cli/cmd_root.go | 11 ++++++++- pkg/cli/profile_resolve_test.go | 10 ++++---- pkg/parity/parity_coverage_test.go | 4 +++- pkg/tapper/tap_orient.go | 6 ++--- pkg/tapper/tap_orient_test.go | 4 ++-- 18 files changed, 59 insertions(+), 49 deletions(-) rename pkg/cli/{cmd_repo_init.go => cmd_init.go} (86%) rename pkg/cli/{cmd_repo_init_test.go => cmd_init_test.go} (95%) diff --git a/README.md b/README.md index 0dcc592..d804c9e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Set `fallbackKeg` to `personal` (or your preferred alias) and configure **2. Initialize a keg** ```bash -tap repo init --keg personal +tap init --keg personal ``` Creates a keg under your first `kegSearchPaths` entry and registers the alias. @@ -183,7 +183,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..95c7712 100644 --- a/docs/README.md +++ b/docs/README.md @@ -91,7 +91,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_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_repo_init.go b/pkg/cli/cmd_init.go similarity index 86% rename from pkg/cli/cmd_repo_init.go rename to pkg/cli/cmd_init.go index cd761b6..6823750 100644 --- a/pkg/cli/cmd_repo_init.go +++ b/pkg/cli/cmd_init.go @@ -9,15 +9,15 @@ import ( "github.com/spf13/cobra" ) -// NewInitCmd returns the `tap repo init` cobra command. +// NewInitCmd returns the `tap 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" +// 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{} @@ -52,12 +52,12 @@ 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 +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 strings.TrimSpace(initOpts.Keg) == "" { diff --git a/pkg/cli/cmd_repo_init_test.go b/pkg/cli/cmd_init_test.go similarity index 95% rename from pkg/cli/cmd_repo_init_test.go rename to pkg/cli/cmd_init_test.go index 787de5e..d0e890d 100644 --- a/pkg/cli/cmd_repo_init_test.go +++ b/pkg/cli/cmd_init_test.go @@ -25,7 +25,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", @@ -41,7 +41,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_with_cwd_without_project", args: []string{ - "repo", "init", + "init", "--cwd", "--keg", "power", "--creator", "me", @@ -58,7 +58,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_with_path_without_project", args: []string{ - "repo", "init", + "init", "--path", ".", "--keg", "workspace", "--creator", "me", @@ -75,7 +75,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 +87,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "local_keg_infers_alias_from_cwd", args: []string{ - "repo", "init", + "init", "--project", "--creator", "me", }, @@ -99,7 +99,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 +111,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 +124,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 +138,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_keg_with_explicit_alias", args: []string{ - "repo", "init", + "init", "--keg", "myblog", "--creator", "me", }, @@ -151,7 +151,7 @@ func TestInitCommand_TableDriven(t *testing.T) { { name: "user_type_infers_alias_from_cwd", args: []string{ - "repo", "init", + "init", "--user", "--creator", "me", }, @@ -268,7 +268,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,7 +279,7 @@ 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) 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_root.go b/pkg/cli/cmd_root.go index ceb10ab..73c505e 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -272,14 +272,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/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 3208826..dc33f43 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -75,7 +75,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/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index 5ecfcbb..3cbdf7b 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -296,7 +296,7 @@ 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)" +// Unresolved: "(none configured; run `tap init` to register one)" // Alias + target: "`alias` → ~/path/to/keg" // Target only: "~/path/to/keg (no alias)" // @@ -305,7 +305,7 @@ func buildOrientPayload(host string, active activeKegLabel, manifestKeg, flight // names the next concrete step instead of saying nothing. 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 == "" { @@ -316,7 +316,7 @@ func formatActiveKegLine(active activeKegLabel) string { if active.Target != "" { return active.Target + " (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..855b781 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,7 +119,7 @@ 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") } From 5917916d5c5243b9bb223f40b576089b405deccf Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Tue, 28 Apr 2026 21:48:08 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(cli):=20tighten=20tap=20init=20?= =?UTF-8?q?=E2=80=94=20alias=20regex,=20platform=20default,=20drop=20tap?= =?UTF-8?q?=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate keg aliases against ^[a-z0-9_-]+$ at InitKeg, initHub, and Config.AddKeg. Lowercase-only, filesystem-portable; rejects uppercase, whitespace, slashes, dots, and non-ASCII. - Fall back to UserDataPath/tapper/kegs (XDG_DATA_HOME on Linux/macOS, LOCALAPPDATA\data on Windows, sandbox-aware via cli-toolkit Runtime) when kegSearchPaths is empty, so a fresh user can run tap init --user without preconfiguring discovery paths. Initialize a fresh user config in-memory when none exists on disk. - Remove tap dir (CLI command, MCP tool, parity coverage, server tests, docs). Tap.ListKegs moves to its own file; Tap.Dir is gone. --- docs/README.md | 1 - pkg/cli/cmd_dir.go | 35 ------------ pkg/cli/cmd_dir_test.go | 65 ---------------------- pkg/cli/cmd_init_test.go | 44 +++++++++++++++ pkg/cli/cmd_root.go | 1 - pkg/mcp/server_test.go | 3 +- pkg/mcp/tools_read.go | 29 ---------- pkg/parity/parity_coverage_test.go | 1 - pkg/parity/parity_read_test.go | 31 ----------- pkg/tapper/alias.go | 28 ++++++++++ pkg/tapper/alias_test.go | 49 ++++++++++++++++ pkg/tapper/config.go | 4 +- pkg/tapper/tap_dir.go | 89 ------------------------------ pkg/tapper/tap_init.go | 39 ++++++++++--- pkg/tapper/tap_list_kegs.go | 30 ++++++++++ 15 files changed, 186 insertions(+), 263 deletions(-) delete mode 100644 pkg/cli/cmd_dir.go delete mode 100644 pkg/cli/cmd_dir_test.go create mode 100644 pkg/tapper/alias.go create mode 100644 pkg/tapper/alias_test.go delete mode 100644 pkg/tapper/tap_dir.go create mode 100644 pkg/tapper/tap_list_kegs.go diff --git a/docs/README.md b/docs/README.md index 95c7712..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 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_init_test.go b/pkg/cli/cmd_init_test.go index d0e890d..d146a67 100644 --- a/pkg/cli/cmd_init_test.go +++ b/pkg/cli/cmd_init_test.go @@ -286,3 +286,47 @@ func TestInitCommand_DestinationValidation(t *testing.T) { 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") + }) + } +} diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index 73c505e..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), 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/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index dc33f43..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"}, 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/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_init.go b/pkg/tapper/tap_init.go index 49f6f28..c414520 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" ) @@ -39,9 +41,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() { @@ -151,7 +154,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 +183,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 +213,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 +255,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 +271,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 +} From f6a26d5e4417870b8d4b7af6bd672e5f7e4282a9 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Tue, 28 Apr 2026 23:49:54 -0500 Subject: [PATCH 4/5] feat(cli): TTY prompt for tap init, path-free user surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TTY-gated interactive prompt for `tap init`. Bare invocation on a TTY now walks alias / location (user|project) / title / creator before delegating to InitKeg. `--non-interactive` opts out; non-TTY (CI, MCP, pipes) skips the prompt unconditionally. Hub branch is intentionally absent — hub init still requires explicit flags. `repoInitInput` gains a matching `NonInteractive` field so the CLI flag and MCP input stay symmetric; MCP forces it true unconditionally because MCP is never interactive. - Path-free user-facing surfaces. Introduces `KegBackendLabel(*keg.Target)` in pkg/tapper, returning a stable identifier (`file-backed`, `:@/`, `in-memory`, scheme string for HTTP) and used by: `tap init` success line, MCP `repo_init` response, `tap orient` active-keg line, and `tap index` rebuild message. `tap info` stays exempt — it is the dedicated locator command. `kegTargetDisplay` and the tilde-anchored path it returned are removed from orient. - README Quick Start renumber: `tap init` is step 1 now that the platform user-data fallback (landed in commit 28bd50d) lets it succeed without `kegSearchPaths` configuration. The previous "set up configuration" step is demoted to an optional Tip block. - Bug fix: `Tap.InitKeg` project-keg branch was wrapping `options.Path` in the resolve-error message instead of `projectPath`, producing `unable to resolve project path "": ...` when the path was inferred from the git root or cwd. --- README.md | 39 +++++----- pkg/cli/cmd_init.go | 131 ++++++++++++++++++++++++++++++++- pkg/cli/cmd_init_test.go | 68 +++++++++++++++-- pkg/mcp/tools_repo.go | 33 +++++---- pkg/tapper/keg_backend.go | 49 ++++++++++++ pkg/tapper/keg_backend_test.go | 44 +++++++++++ pkg/tapper/tap_index.go | 7 +- pkg/tapper/tap_init.go | 10 ++- pkg/tapper/tap_orient.go | 75 +++++++------------ pkg/tapper/tap_orient_test.go | 23 +++--- 10 files changed, 372 insertions(+), 107 deletions(-) create mode 100644 pkg/tapper/keg_backend.go create mode 100644 pkg/tapper/keg_backend_test.go diff --git a/README.md b/README.md index d804c9e..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 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) diff --git a/pkg/cli/cmd_init.go b/pkg/cli/cmd_init.go index 6823750..407e345 100644 --- a/pkg/cli/cmd_init.go +++ b/pkg/cli/cmd_init.go @@ -1,7 +1,10 @@ package cli import ( + "bufio" + "errors" "fmt" + "io" "path/filepath" "strings" @@ -50,6 +53,12 @@ Alias behavior: 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 @@ -60,6 +69,12 @@ 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 { @@ -73,12 +88,12 @@ tap init --keg blog --hub knut --namespace me return err } - if initOpts.LocalDestination() && target != nil { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created at %s", initOpts.Keg, target.Path()) + 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", initOpts.Keg) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "keg %s created (%s)", initOpts.Keg, label) return err }, } @@ -93,6 +108,114 @@ tap init --keg blog --hub knut --namespace me 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_init_test.go b/pkg/cli/cmd_init_test.go index d146a67..8f90c3b 100644 --- a/pkg/cli/cmd_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" @@ -33,8 +34,7 @@ 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", }, @@ -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", @@ -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", @@ -330,3 +328,61 @@ func TestInitCommand_RejectsInvalidAlias(t *testing.T) { }) } } + +// 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/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/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_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 c414520..26be4ab 100644 --- a/pkg/tapper/tap_init.go +++ b/pkg/tapper/tap_init.go @@ -27,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 { @@ -102,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, diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index 3cbdf7b..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,25 +269,27 @@ 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 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 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 init` to register one)" } diff --git a/pkg/tapper/tap_orient_test.go b/pkg/tapper/tap_orient_test.go index 855b781..be9725b 100644 --- a/pkg/tapper/tap_orient_test.go +++ b/pkg/tapper/tap_orient_test.go @@ -125,9 +125,9 @@ func TestTap_Orient_ActiveKeg_NoneConfigured(t *testing.T) { // 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") } From b576dc531573d7508bbb27906917f4e708c68b9f Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Wed, 29 Apr 2026 18:07:46 -0500 Subject: [PATCH 5/5] feat(sandbox): isolate from host, add fixtures + dotfiles packages Re-target the test-env sandbox at clean-room user UX testing rather than live host-tree development. Four threads: - Decouple shell from source. Bind mount moves to /usr/local/src/tapper (read-only, build input only); shell drops in /home/jlrickert with no project context. tap init now lands kegs at the platform default ~/.local/share/tapper/kegs/ instead of failing on a root-owned /workspace/kegs/. - Fixtures. test-env/fixtures/ holds named keg trees; new task sandbox:populate -- | --all | --list copies them into the running container. Seeds a minimal user config on first populate so tap discovers the fixture; no-ops on already-populated, prints a hint if the user already has a config of their own. - Dotfiles refresh. ARG DOTFILES_REV pins the dotfiles checkout so layer caching is deterministic; new task sandbox:refresh-dotfiles resolves live HEAD via git ls-remote and rebuilds without hand-editing the Dockerfile. - Personal dotfiles packages. dots install pulls in jlrickert/zsh (transitively common-shell) and jlrickert/zellij. The SHELL=/bin/bash prefix works around a dots bug where post_install hooks invoke under POSIX sh; drop once dots ships a fix. Tap completion moves from a runtime source <(tap completion zsh) in a hand-rolled .zshrc to Cobra-generated _tap / _keg files dropped into /usr/local/share/zsh/site-functions/ at first boot, picked up by default-fpath compinit. task sandbox:rebuild-tap regenerates them alongside the binary install. Container writable-layer kegs survive shell re-entry and `restart` but are wiped on `rebuild` -- matches the intended `build, optionally populate, then drop into the shell` workflow. --- test-env/Dockerfile | 27 ++++++- test-env/README.md | 108 ++++++++++++++++++++------ test-env/Taskfile.yml | 42 +++++++++- test-env/docker-compose.work.yml | 11 ++- test-env/docker-compose.yml | 8 +- test-env/entrypoint.sh | 13 +++- test-env/fixtures/README.md | 19 +++++ test-env/fixtures/minimal/0/README.md | 8 ++ test-env/fixtures/minimal/keg | 14 ++++ test-env/scripts/populate.sh | 107 +++++++++++++++++++++++++ 10 files changed, 315 insertions(+), 42 deletions(-) create mode 100644 test-env/fixtures/README.md create mode 100644 test-env/fixtures/minimal/0/README.md create mode 100644 test-env/fixtures/minimal/keg create mode 100755 test-env/scripts/populate.sh 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