Skip to content
Merged
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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Run linter
uses: golangci/golangci-lint-action@v7
with:
version: v2.11.2
version: v2.12.2

integration-tests:
needs: unit-tests
Expand Down
4 changes: 4 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ linters:
case:
rules:
json: snake # Enforce snake_case for JSON tags
yaml: snake # Enforce snake_case for YAML tags (e.g. Obsidian-queryable frontmatter)

# Exclusion rules for linters
# Controls which files/patterns are ignored by linters
Expand Down Expand Up @@ -170,6 +171,9 @@ linters:
- linters:
- dupl # Duplicate code detection
path: _test\.go # Test files often have similar structures for readability
- linters:
- goconst # Repeated string literals in tests aid readability over shared constants
path: _test\.go
- linters:
- testifylint # Testify linter
path: _test\.go
Expand Down
5 changes: 4 additions & 1 deletion arc-paste/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import (
// SQLite controls) is what protects the data.
const dbDirMode = 0o755

// robotsPath is the well-known URL path for the robots.txt file.
const robotsPath = "/robots.txt"

func main() {
if err := run(); err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -110,7 +113,7 @@ func newRouter(handlers *paste.Handlers) *echo.Echo {
// Keep this function in sync with arc-paste/Caddyfile if either changes.
func arcPasteAllowedPath(p string) bool {
switch p {
case "/api/paste", "/robots.txt":
case "/api/paste", robotsPath:
return true
}
if strings.HasPrefix(p, "/_app/") || strings.HasPrefix(p, "/api/paste/") {
Expand Down
8 changes: 4 additions & 4 deletions cmd/arc/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ var aiSessionCmd = &cobra.Command{
var errSkipSession = errors.New("skip session")

var aiSessionStartCmd = &cobra.Command{
Use: "start",
Use: cmdStart,
Short: "Start a new AI session",
RunE: func(cmd *cobra.Command, args []string) error {
useStdin, _ := cmd.Flags().GetBool("stdin")
Expand Down Expand Up @@ -170,7 +170,7 @@ func runSessionStart(cmd *cobra.Command, useStdin bool) error {
// aiSessionListCmd lists AI sessions sorted by start time (newest first).
// Supports --json for machine-readable output.
var aiSessionListCmd = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List AI sessions",
RunE: func(cmd *cobra.Command, args []string) error {
projID, err := getProjectID()
Expand Down Expand Up @@ -215,7 +215,7 @@ var aiSessionListCmd = &cobra.Command{
// aiSessionShowCmd displays details for a single AI session, including
// its registered agents. Supports --json for machine-readable output.
var aiSessionShowCmd = &cobra.Command{
Use: "show <id>",
Use: useShowID,
Short: "Show AI session details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -344,7 +344,7 @@ var aiAgentRegisterCmd = &cobra.Command{
// type, model, status, duration, tokens, and tool use count.
// Requires --session to identify the parent session.
var aiAgentShowCmd = &cobra.Command{
Use: "show <id>",
Use: useShowID,
Short: "Show AI agent details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
129 changes: 99 additions & 30 deletions cmd/arc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,43 @@ const setArgsCount = 2
// dottedKeyParts is the number of parts produced by splitting a dotted config key.
const dottedKeyParts = 2

// Config key names, used as the canonical dotted identifiers throughout the
// config commands.
const (
cliServerKey = "cli.server"
shareAuthorKey = "share.author"
shareServerKey = "share.server"
updatesChannelKey = "updates.channel"
plansDirKey = "plans.dir"
serverPortKey = "server.port"
serverDBPathKey = "server.db_path"
)

// projectVar is the template variable name for a project's slug in plans.dir.
const projectVar = "project"

// cmdEdit is the cobra Use string for the "config edit" sub-command.
const cmdEdit = "edit"

// legacyAliases maps pre-TOML key names to their current dotted equivalents.
// These are checked before the Levenshtein fallback in normalizeKey so that
// well-known old names always produce the correct "did you mean" hint.
var legacyAliases = map[string]string{
"server_url": "cli.server",
"share_author": "share.author",
"share_server": "share.server",
"channel": "updates.channel",
"server_url": cliServerKey,
"share_author": shareAuthorKey,
"share_server": shareServerKey,
"channel": updatesChannelKey,
}

// recognizedKeys is the canonical list of all valid config key names.
var recognizedKeys = []string{
"cli.server",
"server.port",
"server.db_path",
"share.author",
"share.server",
"updates.channel",
cliServerKey,
plansDirKey,
serverPortKey,
serverDBPathKey,
shareAuthorKey,
shareServerKey,
updatesChannelKey,
}

// configCmd is the parent command for all config sub-commands.
Expand All @@ -51,7 +70,7 @@ var configCmd = &cobra.Command{

// configListCmd prints all configuration key=value pairs.
var configListCmd = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List all configuration values",
RunE: runConfigList,
}
Expand Down Expand Up @@ -96,15 +115,20 @@ var configPathCmd = &cobra.Command{

// configEditCmd opens the config file in $EDITOR for direct editing.
var configEditCmd = &cobra.Command{
Use: "edit",
Use: cmdEdit,
Short: "Open the config file in $EDITOR",
RunE: runConfigEdit,
}

// resolvedFlag enables path resolution for keys that support it (plans.dir).
var resolvedFlag bool

// init registers all config sub-commands with the root command.
func init() {
configCmd.AddCommand(configListCmd, configGetCmd, configSetCmd, configUnsetCmd, configPathCmd, configEditCmd)
rootCmd.AddCommand(configCmd)
configGetCmd.Flags().BoolVar(&resolvedFlag, "resolved", false,
"resolve {vars} and ~ to an absolute path (plans.dir only)")
}

// runConfigList prints all settings grouped by TOML section.
Expand Down Expand Up @@ -134,18 +158,18 @@ func runConfigList(cmd *cobra.Command, args []string) error {
fmt.Printf(" %-10s = %s%s\n", label, value, tag)
}
fmt.Println("[cli]")
printRow("cli.server", cfg.CLI.Server)
printRow(cliServerKey, cfg.CLI.Server)
fmt.Println()
fmt.Println("[server]")
printRow("server.port", strconv.Itoa(cfg.Server.Port))
printRow("server.db_path", cfg.Server.DBPath)
printRow(serverPortKey, strconv.Itoa(cfg.Server.Port))
printRow(serverDBPathKey, cfg.Server.DBPath)
fmt.Println()
fmt.Println("[share]")
printRow("share.author", cfg.Share.Author)
printRow("share.server", cfg.Share.Server)
printRow(shareAuthorKey, cfg.Share.Author)
printRow(shareServerKey, cfg.Share.Server)
fmt.Println()
fmt.Println("[updates]")
printRow("updates.channel", cfg.Updates.Channel)
printRow(updatesChannelKey, cfg.Updates.Channel)
fmt.Println()
fmt.Printf("Config: %s\n", p)
return nil
Expand All @@ -161,6 +185,12 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if resolvedFlag && key != plansDirKey {
return errors.New("--resolved is only supported for plans.dir")
}
if resolvedFlag && key == plansDirKey {
return runConfigGetResolved(cfg)
}
value := getKey(cfg, key)
if outputJSON {
outputResult(map[string]string{key: value})
Expand All @@ -170,6 +200,41 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
return nil
}

// runConfigGetResolved expands plans.dir against the active project's
// template variables and current working directory, then prints the resulting
// absolute path. It hard-errors when no project can be resolved.
func runConfigGetResolved(cfg *cfgpkg.Config) error {
c, err := getClient()
if err != nil {
return err
}
wsID, _, _, err := resolveProject()
if err != nil {
return err
}
proj, err := c.GetProject(wsID)
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current directory: %w", err)
}
dir, err := cfgpkg.ExpandPlansDir(cfg.Plans.Dir, map[string]string{
projectVar: cfgpkg.SanitizeSlug(proj.Name),
"prefix": proj.Prefix,
}, cwd)
if err != nil {
return err
}
if outputJSON {
outputResult(map[string]string{plansDirKey: dir})
return nil
}
fmt.Println(dir)
return nil
}

// runConfigSet validates and persists a new value for a config key.
func runConfigSet(cmd *cobra.Command, args []string) error {
key, err := normalizeKey(args[0])
Expand Down Expand Up @@ -318,17 +383,19 @@ func levenshtein(a, b string) int {
// getKey returns the string form of the config field for key.
func getKey(cfg *cfgpkg.Config, key string) string {
switch key {
case "cli.server":
case cliServerKey:
return cfg.CLI.Server
case "server.port":
case plansDirKey:
return cfg.Plans.Dir
case serverPortKey:
return strconv.Itoa(cfg.Server.Port)
case "server.db_path":
case serverDBPathKey:
return cfg.Server.DBPath
case "share.author":
case shareAuthorKey:
return cfg.Share.Author
case "share.server":
case shareServerKey:
return cfg.Share.Server
case "updates.channel":
case updatesChannelKey:
return cfg.Updates.Channel
}
return ""
Expand All @@ -338,21 +405,23 @@ func getKey(cfg *cfgpkg.Config, key string) string {
// It then validates the whole config and returns any error.
func setKey(cfg *cfgpkg.Config, key, value string) error {
switch key {
case "cli.server":
case cliServerKey:
cfg.CLI.Server = value
case "server.port":
case plansDirKey:
cfg.Plans.Dir = value
case serverPortKey:
n, err := strconv.Atoi(value)
if err != nil {
return errors.New("server.port: must be an integer")
}
cfg.Server.Port = n
case "server.db_path":
case serverDBPathKey:
cfg.Server.DBPath = value
case "share.author":
case shareAuthorKey:
cfg.Share.Author = value
case "share.server":
case shareServerKey:
cfg.Share.Server = value
case "updates.channel":
case updatesChannelKey:
cfg.Updates.Channel = value
}
if err := cfgpkg.Validate(cfg); err != nil {
Expand Down
8 changes: 3 additions & 5 deletions cmd/arc/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
cfgpkg "github.com/sentiolabs/arc/internal/config"
)

const dbPathKey = "server.db_path"

func TestConfigSetGetRoundTrip(t *testing.T) {
dir := t.TempDir()
configPath = filepath.Join(dir, "config.toml")
Expand Down Expand Up @@ -94,7 +92,7 @@ func TestNormalizeKeyValid(t *testing.T) {
validKeys := []string{
"cli.server",
"server.port",
dbPathKey,
serverDBPathKey,
"share.author",
"share.server",
"updates.channel",
Expand All @@ -116,8 +114,8 @@ func TestNormalizeKeyNormalizes(t *testing.T) {
if err != nil {
t.Errorf("normalizeKey(server.db-path): %v", err)
}
if got != dbPathKey {
t.Errorf("got %q, want %s", got, dbPathKey)
if got != serverDBPathKey {
t.Errorf("got %q, want %s", got, serverDBPathKey)
}
}

Expand Down
9 changes: 6 additions & 3 deletions cmd/arc/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (
// readable by other tools (AGENTS.md, CLAUDE.md).
const filePermissions = 0o644

// agentsFileName is the name of the agent instructions file created by init.
const agentsFileName = "AGENTS.md"

var initCmd = &cobra.Command{
Use: "init [name]",
Short: "Initialize arc in the current directory",
Expand Down Expand Up @@ -166,7 +169,7 @@ func runInit(cmd *cobra.Command, args []string) error {

// addLandingThePlaneInstructions adds "landing the plane" instructions to AGENTS.md
func addLandingThePlaneInstructions(verbose bool) error {
filename := "AGENTS.md"
filename := agentsFileName

// Get the full AGENTS.md content from template
agentsMdContent, err := templates.RenderAgentsMd()
Expand Down Expand Up @@ -238,7 +241,7 @@ func updateClaudeMdReference(verbose bool) error {

// Generate the reference text from template
reference, err := templates.RenderClaudeMdReference(templates.ClaudeMdReferenceData{
AgentsFile: "AGENTS.md",
AgentsFile: agentsFileName,
})
if err != nil {
return fmt.Errorf("failed to render template: %w", err)
Expand All @@ -262,7 +265,7 @@ func updateClaudeMdReference(verbose bool) error {
contentStr := string(content)

// Check if it already references AGENTS.md for session completion
if strings.Contains(contentStr, "AGENTS.md") && strings.Contains(contentStr, "Landing the Plane") {
if strings.Contains(contentStr, agentsFileName) && strings.Contains(contentStr, "Landing the Plane") {
if verbose {
fmt.Printf(" %s already references AGENTS.md for session completion\n", filename)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/arc/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func init() {
// labelListCmd lists all global labels.
// Output is a table (NAME, COLOR, DESCRIPTION) or JSON when --json is set.
var labelListCmd = &cobra.Command{
Use: "list",
Use: cmdList,
Short: "List all labels",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := getClient()
Expand Down
Loading
Loading