feat: project init with config package and root command

Go module, cobra root command, config resolution (CTASK_ROOT, CTASK_AGENT, EnvVars) with tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:28:24 -04:00
commit ab56ddfff0
7 changed files with 192 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
ctask.exe
*.exe
+24
View File
@@ -0,0 +1,24 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var version = "0.1.0"
var rootCmd = &cobra.Command{
Use: "ctask",
Short: "Create and manage AI-agent task workspaces",
Long: "ctask is a local CLI that creates and manages named AI-agent task workspaces so developers can start, resume, and organize work more safely and predictably.",
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
rootCmd.Version = version
rootCmd.SetVersionTemplate(fmt.Sprintf("ctask v%s\n", version))
}
+10
View File
@@ -0,0 +1,10 @@
module github.com/warrenronsiek/ctask
go 1.26.1
require github.com/spf13/cobra v1.10.2
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"os"
"path/filepath"
"strings"
)
// ResolveRoot returns the absolute workspace root path.
// Reads CTASK_ROOT env var, falls back to ~/ai-workspaces (or %USERPROFILE%\ai-workspaces on Windows).
func ResolveRoot() string {
root := os.Getenv("CTASK_ROOT")
if root == "" {
home, _ := os.UserHomeDir()
return filepath.Join(home, "ai-workspaces")
}
// Expand leading tilde
if strings.HasPrefix(root, "~/") || root == "~" {
home, _ := os.UserHomeDir()
root = filepath.Join(home, root[2:])
}
abs, err := filepath.Abs(root)
if err != nil {
return root
}
return abs
}
// ResolveAgent returns the agent command.
// Reads CTASK_AGENT env var, falls back to "claude".
func ResolveAgent() string {
agent := os.Getenv("CTASK_AGENT")
if agent == "" {
return "claude"
}
return agent
}
// EnvVars returns the environment variables to export into child sessions.
func EnvVars(slug, mode, root, workspace, category string) map[string]string {
return map[string]string{
"CTASK_TASK": slug,
"CTASK_MODE": mode,
"CTASK_ROOT": root,
"CTASK_WORKSPACE": workspace,
"CTASK_CATEGORY": category,
}
}
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestDefaultRoot(t *testing.T) {
os.Unsetenv("CTASK_ROOT")
root := ResolveRoot()
home, _ := os.UserHomeDir()
expected := filepath.Join(home, "ai-workspaces")
if root != expected {
t.Errorf("expected %q, got %q", expected, root)
}
}
func TestCustomRoot(t *testing.T) {
dir := t.TempDir()
os.Setenv("CTASK_ROOT", dir)
defer os.Unsetenv("CTASK_ROOT")
root := ResolveRoot()
if root != dir {
t.Errorf("expected %q, got %q", dir, root)
}
}
func TestRootResolvesRelative(t *testing.T) {
os.Setenv("CTASK_ROOT", "relative/path")
defer os.Unsetenv("CTASK_ROOT")
root := ResolveRoot()
if !filepath.IsAbs(root) {
t.Errorf("expected absolute path, got %q", root)
}
}
func TestDefaultAgent(t *testing.T) {
os.Unsetenv("CTASK_AGENT")
agent := ResolveAgent()
if agent != "claude" {
t.Errorf("expected \"claude\", got %q", agent)
}
}
func TestCustomAgent(t *testing.T) {
os.Setenv("CTASK_AGENT", "aider")
defer os.Unsetenv("CTASK_AGENT")
agent := ResolveAgent()
if agent != "aider" {
t.Errorf("expected \"aider\", got %q", agent)
}
}
func TestRootResolvesTilde(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("tilde expansion not typical on Windows")
}
os.Setenv("CTASK_ROOT", "~/my-workspaces")
defer os.Unsetenv("CTASK_ROOT")
root := ResolveRoot()
if root[0] == '~' {
t.Errorf("tilde not expanded: %q", root)
}
if !filepath.IsAbs(root) {
t.Errorf("expected absolute path, got %q", root)
}
}
func TestEnvVars(t *testing.T) {
vars := EnvVars("my-slug", "local", "/abs/root", "/abs/root/cat/ws", "general")
expected := map[string]string{
"CTASK_TASK": "my-slug",
"CTASK_MODE": "local",
"CTASK_ROOT": "/abs/root",
"CTASK_WORKSPACE": "/abs/root/cat/ws",
"CTASK_CATEGORY": "general",
}
for k, v := range expected {
if vars[k] != v {
t.Errorf("env %s: expected %q, got %q", k, v, vars[k])
}
}
}
+13
View File
@@ -0,0 +1,13 @@
package main
import (
"os"
"github.com/warrenronsiek/ctask/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}