Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/guild/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package main is the entry point for the guild binary.
//
// guild bundles the lore CLI, quest CLI, and MCP stdio server in one
// static binary. See https://github.com/mathomhaus/guild for docs.
// single binary. See https://github.com/mathomhaus/guild for docs.
package main

import (
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/hints.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (
// lets operators inspect the rule table, read per-rule stats, and
// manually enable/disable/prune rules.
var hintsCmd = &cobra.Command{
Use: "hints",
Short: "hint engine inspection + administration",
Use: "hints",
Short: "hint engine inspection + administration",
GroupID: "inspection",
Long: `Inspect and administer the SQL-backed hint engine (QUEST-58).

The engine fires advisory lines on top of tool responses. The launch-9
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/init_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (
)

var initCmd = &cobra.Command{
Use: "init",
Short: "scaffold AGENTS.md and register this repo with guild",
Use: "init",
Short: "scaffold AGENTS.md and register this repo with guild",
GroupID: "core",
Long: `guild init — per-project scaffold

Run inside a git repository. Detects the project name, shows a plan
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/lore_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func openLoreDB(ctx context.Context) (*sql.DB, error) {
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("cli: ensure ~/.guild: %w", err)
}
db, err := storage.Open(ctx, path)
Expand Down Expand Up @@ -479,7 +479,8 @@ func bindLoreRegistryVerb[I, O any](parent *cobra.Command, spec *command.Command
// tolerates a nil Embed pointer per ADR-003 nil-safety.
func buildCLILoreDeps() command.Deps {
d := command.Deps{
OpenDB: openLoreDB,
OpenDB: openLoreDB,
OpenQuestDB: openQuestDB,
ResolveProj: func(ctx context.Context, argProject string) (string, error) {
db, err := openLoreDB(ctx)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/quest.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func openQuestDB(ctx context.Context) (*sql.DB, error) {
}
if path != ":memory:" && !strings.HasPrefix(path, ":memory:") {
if dir := filepath.Dir(path); dir != "." && dir != "/" {
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
}
Expand Down
33 changes: 21 additions & 12 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ func SetVersion(version, commit, date string) {
var rootCmd = &cobra.Command{
Use: "guild",
Short: "persistent cognition for AI agents — task + knowledge lifecycle",
Long: `guild bundles three modes in one static binary:
Long: `guild bundles two domains, two surfaces, and one store in a single binary:

guild lore <verb> knowledge lifecycle (inscribe, appraise, study, ...)
guild quest <verb> task lifecycle (post, accept, clear, ...)
guild mcp serve MCP stdio server for AI agents
guild mcp serve MCP stdio surface for AI agents

The lore, quest, and MCP surfaces share one SQLite-backed store under
~/.guild/. See https://github.com/mathomhaus/guild for docs.
The CLI and MCP surfaces share lore and quest data in one SQLite-backed store
under ~/.guild/. See https://github.com/mathomhaus/guild for docs.

Next step — if you haven't yet:

Expand All @@ -54,32 +54,36 @@ Environment variables:
}

var versionCmd = &cobra.Command{
Use: "version",
Short: "print guild version information",
Use: "version",
Short: "print guild version information",
GroupID: "inspection",
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("guild version=%s commit=%s date=%s\n", buildVersion, buildCommit, buildDate)
},
}

var loreCmd = &cobra.Command{
Use: "lore",
Short: "knowledge lifecycle (read/write/decay/supersede)",
Use: "lore",
Short: "knowledge lifecycle (inscribe/appraise/study/reforge)",
GroupID: "core",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}

var questCmd = &cobra.Command{
Use: "quest",
Short: "task lifecycle (post/accept/journal/clear/coordinate)",
Use: "quest",
Short: "task lifecycle (post/accept/journal/clear/coordinate)",
GroupID: "core",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}

var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "MCP server subcommands",
Use: "mcp",
Short: "MCP server subcommands",
GroupID: "core",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
Expand Down Expand Up @@ -121,6 +125,11 @@ func SetUpgradeNudge(fn func() string) {
}

func init() {
rootCmd.AddGroup(
&cobra.Group{ID: "core", Title: "Core"},
&cobra.Group{ID: "inspection", Title: "Inspection"},
)

// PersistentPreRun fires before every subcommand. We use it to emit
// an upgrade-available nudge when a newer guild release exists and
// stderr is a TTY. The isatty check happens here (not in SetUpgradeNudge)
Expand Down
20 changes: 20 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ func TestRootHelp(t *testing.T) {
t.Errorf("root --help output missing sub-command %q\n%s", want, out)
}
}
for _, want := range []string{
"Core",
"Inspection",
"knowledge lifecycle (inscribe/appraise/study/reforge)",
"dashboard: last briefing, oath, top bounty, and parallelism",
} {
if !strings.Contains(out, want) {
t.Errorf("root --help output missing grouped help text %q\n%s", want, out)
}
}
for _, unwanted := range []string{
"three modes",
"static binary",
"read/write/decay/supersede",
"alias of quest bounties",
} {
if strings.Contains(out, unwanted) {
t.Errorf("root --help output still contains stale phrasing %q\n%s", unwanted, out)
}
}
// QUEST-10: the root help should nudge the user toward the
// natural next step after installing guild. Check for two
// action-phrases that should appear in the Long description.
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/status_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import (
)

var statusCmd = &cobra.Command{
Use: "status",
Short: "dashboard: last briefing, oath, top bounty, parallelism (alias of quest bounties)",
Use: "status",
Short: "dashboard: last briefing, oath, top bounty, and parallelism",
GroupID: "core",
Long: `Mid-session reorientation — shows the same snapshot the session-start
briefing prints, on demand.

Expand Down
5 changes: 3 additions & 2 deletions internal/cli/telemetry_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (
const optInHint = "telemetry is disabled — enable with [telemetry]\n usage_log = true in ~/.guild/config.toml to start recording resp_bytes"

var telemetryCmd = &cobra.Command{
Use: "telemetry",
Short: "telemetry analytics (usage log, token estimates)",
Use: "telemetry",
Short: "telemetry analytics (usage log, token estimates)",
GroupID: "inspection",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
Expand Down
1 change: 1 addition & 0 deletions internal/command/cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (c *Command[I, O]) BindCobra(parent *cobra.Command, d Deps) {
Aliases: c.CLIAliases,
Short: c.Short,
Long: c.Long,
Example: c.CLIExample,
Args: cobraPositionalValidator(c.Args),
SilenceUsage: true,
}
Expand Down
7 changes: 7 additions & 0 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type Command[I, O any] struct {
// Long is the extended help for cobra's Long field and the MCP
// tool's Description. If empty, Short is used for both.
Long string
// CLIExample is an optional Cobra-only examples block. It is kept
// out of MCP descriptions so tool schemas stay inside token budgets.
CLIExample string
// MCPOnly suppresses CLI registration (e.g. guild_session_start).
MCPOnly bool
// CLIOnly suppresses MCP registration (e.g. guild mcp-install).
Expand Down Expand Up @@ -116,6 +119,10 @@ type Deps struct {
// (e.g. quest_post with spec=). Nil means the feature is unavailable
// for that surface / test setup.
OpenLoreDB func(ctx context.Context) (*sql.DB, error)
// OpenQuestDB, when non-nil, opens the quest SQLite database. Lore-side
// diagnostics use this to report quest-corpus embedding health alongside
// lore-corpus health.
OpenQuestDB func(ctx context.Context) (*sql.DB, error)
// EvaluateHints, when non-nil, is called by the MCP handler wrapper
// after each successful tool invocation. Returns a HintFire the
// wrapper formats and prepends/appends to the tool's output body.
Expand Down
4 changes: 1 addition & 3 deletions internal/install/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,6 @@ var Clients = []Client{
InstallArgv: func(b string) []string {
return []string{"codex", "mcp", "add", "guild", "--", b, "mcp", "serve"}
},
// ListArgv left nil until `codex mcp list` output shape is verified
// against a real run; nil disables the pre-check and the install
// attempt proceeds as before.
ListArgv: func() []string { return []string{"codex", "mcp", "list"} },
},
}
3 changes: 3 additions & 0 deletions internal/install/clients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ func TestClients_WellFormed(t *testing.T) {
if c.CLIProbe == "" && c.ConfigProbe == "" {
t.Errorf("client %q has neither CLIProbe nor ConfigProbe", c.Name)
}
if c.ListArgv == nil {
t.Errorf("client %q has nil ListArgv; repeat init cannot pre-check registration", c.Name)
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion internal/install/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ type InitOptions struct {
// execCmdFn is passed through to MCPInstall when invoking MCP registration.
// Nil → real exec.Command. Injected in tests to capture the registration call.
execCmdFn func(name string, arg ...string) *exec.Cmd
// lookPathFn is passed through to MCPInstall when checking detected client
// CLI binaries. Nil → exec.LookPath.
lookPathFn func(file string) (string, error)
// executableFn is passed through to MCPInstall for binary-path resolution.
// Nil → os.Executable. Injected in tests so CI runners (which have no
// durable guild binary installed) can resolve to a temp-file path and
Expand Down Expand Up @@ -297,6 +300,7 @@ func Init(ctx context.Context, repoRoot string, opts InitOptions) (*InitResult,
clients: detected,
executableFn: execFn,
execCmdFn: opts.execCmdFn,
lookPathFn: opts.lookPathFn,
}
if _, err := MCPInstall(ctx, mcpOpts); err != nil {
return nil, fmt.Errorf("install: mcp register: %w", err)
Expand Down Expand Up @@ -422,7 +426,7 @@ func resolveDBPaths(opts InitOptions) (loreDB, questDB string, err error) {
return "", "", fmt.Errorf("install: resolve home dir: %w", err)
}
guildDir := filepath.Join(home, ".guild")
if err := os.MkdirAll(guildDir, 0o755); err != nil {
if err := os.MkdirAll(guildDir, 0o700); err != nil {
return "", "", fmt.Errorf("install: create ~/.guild: %w", err)
}
if loreDB == "" {
Expand Down
45 changes: 45 additions & 0 deletions internal/install/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,51 @@ func TestInit_MCPRegistration_YesFlagInvokesExec(t *testing.T) {
}
}

func TestInit_MCPRegistration_SkipsDetectedClientWhenCLIMissing(t *testing.T) {
ctx := context.Background()
dir := makeRepo(t, "missingcli")
loreDB, questDB := testDBPaths(t)
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.WriteFile(filepath.Join(home, ".missing-client.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write config probe: %v", err)
}

var out bytes.Buffer
var calls [][]string
client := Client{
Name: "Missing CLI",
CLIProbe: "missing-client-cli",
ConfigProbe: "~/.missing-client.json",
InstallArgv: func(b string) []string {
return []string{"missing-client-cli", "mcp", "add", "guild", "--", b, "mcp", "serve"}
},
}

_, err := Init(ctx, dir, InitOptions{
Yes: true,
Out: &out,
In: &bytes.Buffer{},
LoreDBPath: loreDB,
QuestDBPath: questDB,
clients: []Client{client},
execCmdFn: recordingExec(&calls),
executableFn: fakeExecutable(t),
lookPathFn: func(string) (string, error) {
return "", exec.ErrNotFound
},
})
if err != nil {
t.Fatalf("Init: %v", err)
}
if len(calls) != 0 {
t.Fatalf("registration exec calls = %v, want none", calls)
}
if !strings.Contains(out.String(), "skipping Missing CLI: missing-client-cli binary not found on PATH") {
t.Fatalf("output missing missing-CLI skip notice:\n%s", out.String())
}
}

// Interactive path: user types "y" — init must invoke registration.
func TestInit_MCPRegistration_InteractiveYes(t *testing.T) {
ctx := context.Background()
Expand Down
6 changes: 3 additions & 3 deletions internal/install/mcp_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//
// Default UX (no flags):
//
// guild binary: /usr/local/bin/guild
// guild binary: /path/to/guild
//
// Detected agent clients:
// ✓ Claude Code
Expand All @@ -15,7 +15,7 @@
// Run the command for each agent you use:
//
// # Claude Code
// claude mcp add guild --scope user -- /usr/local/bin/guild mcp serve
// claude mcp add guild --scope user -- /path/to/guild mcp serve
//
// With --run: shells out to each detected client's CLI with a per-command
// confirmation prompt.
Expand Down Expand Up @@ -264,7 +264,7 @@ func MCPInstall(ctx context.Context, opts MCPInstallOptions) (*MCPInstallResult,
// user knows which CLI to install (issue #48).
binaryName := instr.Argv[0]
if _, err := opts.lookPathFn(binaryName); err != nil {
fmt.Fprintf(opts.Out, "skipping %s: %s not on PATH\n", instr.Name, binaryName)
fmt.Fprintf(opts.Out, "skipping %s: %s binary not found on PATH\n", instr.Name, binaryName)
result.SkippedMissingCLI = append(result.SkippedMissingCLI, instr.Name)
continue
}
Expand Down
6 changes: 4 additions & 2 deletions internal/install/mcp_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -544,7 +545,7 @@ func TestMCPInstall_Run_SkipsWhenCLIMissing(t *testing.T) {
if len(result.Ran) != 0 {
t.Errorf("Ran = %v, want empty", result.Ran)
}
if !strings.Contains(buf.String(), "skipping Bogus: "+badBinary+" not on PATH") {
if !strings.Contains(buf.String(), "skipping Bogus: "+badBinary+" binary not found on PATH") {
t.Errorf("expected missing-CLI notice; got:\n%s", buf.String())
}
}
Expand Down Expand Up @@ -669,13 +670,14 @@ func TestMCPInstall_Run_RegistersWhenAbsent(t *testing.T) {
// pre-check. It accepts the common CLI output formats and rejects
// incidental mentions of "guild" inside command-value strings.
func TestScanForGuildEntry(t *testing.T) {
guildBin := filepath.Join(t.TempDir(), "bin", "guild")
cases := []struct {
name string
in string
want bool
wantPath string
}{
{"claude human", "guild: /usr/local/bin/guild mcp serve\n", true, "/usr/local/bin/guild"},
{"claude human", "guild: " + guildBin + " mcp serve\n", true, guildBin},
{"list marker", "- guild\n- other\n", true, ""},
{"bare token", "guild\n", true, ""},
{"mixed list", " * other\n * guild: /bin/guild\n", true, "/bin/guild"},
Expand Down
12 changes: 12 additions & 0 deletions internal/lore/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func Catalog(ctx context.Context, db *sql.DB, p *CatalogParams) (*CatalogResult,
if strings.TrimSpace(p.Dir) == "" {
return nil, fmt.Errorf("lore: catalog: dir required")
}
if p.Kind != "" && !isValidKind(p.Kind) {
return nil, fmt.Errorf("lore: catalog: invalid kind %q (valid kinds: %s)", p.Kind, validKindList())
}

info, err := os.Stat(p.Dir)
if err != nil {
Expand Down Expand Up @@ -222,3 +225,12 @@ func inferKindFromPath(path string) Kind {
}
return KindResearch
}

func validKindList() string {
kinds := AllKinds()
names := make([]string, 0, len(kinds))
for _, kind := range kinds {
names = append(names, string(kind))
}
return strings.Join(names, ", ")
}
Loading