├── .gitignore ├── ai └── prompt.md ├── Makefile ├── aic ├── read_stats.go ├── token_ctx.go ├── token_raw.go ├── file_read.go ├── prompt_token.go ├── prompt_tokenizer.go ├── token_dollar.go ├── file_collect.go ├── prompt_reader.go ├── gitignore.go ├── token_at.go ├── ai_dir.go └── cli.go ├── go.mod ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | tmp -------------------------------------------------------------------------------- /ai/prompt.md: -------------------------------------------------------------------------------- 1 | # LLM MODEL THIS IS MY PROMPT: 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | go run main.go 3 | 4 | kill: 5 | lsof -ti:8000 | xargs kill -9 || true 6 | -------------------------------------------------------------------------------- /aic/read_stats.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | type ReadStats struct { 4 | Files int 5 | Lines int 6 | Chars int 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phillip-england/aic 2 | 3 | go 1.25.3 4 | 5 | require github.com/atotto/clipboard v0.1.4 // indirect 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/phillip-england/aic/aic" 7 | ) 8 | 9 | func main() { 10 | if err := aic.NewCLI().Run(os.Args[1:]); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /aic/token_ctx.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | type TokenCtx struct { 4 | reader *PromptReader 5 | index int 6 | } 7 | 8 | func (c *TokenCtx) bind(r *PromptReader, index int) { 9 | c.reader = r 10 | c.index = index 11 | } 12 | 13 | func (c *TokenCtx) Reader() *PromptReader { return c.reader } 14 | func (c *TokenCtx) Index() int { return c.index } 15 | 16 | func (c *TokenCtx) Prev() PromptToken { 17 | if c.reader == nil { 18 | return nil 19 | } 20 | i := c.index - 1 21 | if i < 0 || i >= len(c.reader.Tokens) { 22 | return nil 23 | } 24 | return c.reader.Tokens[i] 25 | } 26 | 27 | func (c *TokenCtx) Next() PromptToken { 28 | if c.reader == nil { 29 | return nil 30 | } 31 | i := c.index + 1 32 | if i < 0 || i >= len(c.reader.Tokens) { 33 | return nil 34 | } 35 | return c.reader.Tokens[i] 36 | } 37 | -------------------------------------------------------------------------------- /aic/token_raw.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import "fmt" 4 | 5 | type RawToken struct { 6 | TokenCtx 7 | literal string 8 | } 9 | 10 | func NewRawToken(lit string) PromptToken { 11 | return &RawToken{literal: lit} 12 | } 13 | 14 | func (t *RawToken) Type() PromptTokenType { return PromptTokenRaw } 15 | func (t *RawToken) Literal() string { return t.literal } 16 | 17 | func (t *RawToken) Validate(d *AiDir) error { return nil } 18 | 19 | func (t *RawToken) AfterValidate(r *PromptReader, index int) error { 20 | t.bind(r, index) 21 | return nil 22 | } 23 | 24 | func (t *RawToken) Render(d *AiDir) (string, error) { 25 | return t.literal, nil 26 | } 27 | 28 | func (t *RawToken) String() string { 29 | display := t.literal 30 | if len(display) > 20 { 31 | display = display[:20] + "..." 32 | } 33 | return fmt.Sprintf("<%s: %q>", t.Type().String(), display) 34 | } 35 | -------------------------------------------------------------------------------- /aic/file_read.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "unicode/utf8" 9 | ) 10 | 11 | func ReadTextFile(absPath string) (content string, ok bool, stats ReadStats, err error) { 12 | b, err := os.ReadFile(absPath) 13 | if err != nil { 14 | return "", false, ReadStats{}, fmt.Errorf("read file %s: %w", absPath, err) 15 | } 16 | 17 | // Skip binary-ish content quickly. 18 | if bytes.IndexByte(b, 0) >= 0 || !utf8.Valid(b) { 19 | return "", false, ReadStats{}, nil 20 | } 21 | 22 | s := string(b) 23 | s = strings.ReplaceAll(s, "\r\n", "\n") 24 | 25 | lines := 0 26 | if s != "" { 27 | // Count '\n' lines; if no trailing newline, still a line. 28 | lines = strings.Count(s, "\n") 29 | if !strings.HasSuffix(s, "\n") { 30 | lines++ 31 | } 32 | } 33 | 34 | return s, true, ReadStats{Lines: lines, Chars: len(s)}, nil 35 | } 36 | -------------------------------------------------------------------------------- /aic/prompt_token.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import "fmt" 4 | 5 | type PromptTokenType int 6 | 7 | const ( 8 | PromptTokenRaw PromptTokenType = iota 9 | PromptTokenAt 10 | PromptTokenDollar 11 | ) 12 | 13 | func (t PromptTokenType) String() string { 14 | switch t { 15 | case PromptTokenRaw: 16 | return "Raw" 17 | case PromptTokenAt: 18 | return "At" 19 | case PromptTokenDollar: 20 | return "Dollar" 21 | default: 22 | return "Unknown" 23 | } 24 | } 25 | 26 | type PromptToken interface { 27 | fmt.Stringer 28 | 29 | Type() PromptTokenType 30 | Literal() string 31 | 32 | // Validate runs during the validation pass, using AiDir context. 33 | // If it returns an error, the reader downgrades this token to Raw. 34 | Validate(d *AiDir) error 35 | 36 | // AfterValidate runs after validation/downgrade and binds reader/index. 37 | AfterValidate(r *PromptReader, index int) error 38 | 39 | // Render produces this token's contribution to the final output string. 40 | Render(d *AiDir) (string, error) 41 | 42 | Reader() *PromptReader 43 | Index() int 44 | Prev() PromptToken 45 | Next() PromptToken 46 | } 47 | -------------------------------------------------------------------------------- /aic/prompt_tokenizer.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | func TokenizePrompt(prompt string) []PromptToken { 4 | if prompt == "" { 5 | return nil 6 | } 7 | return scanPrompt(prompt) 8 | } 9 | 10 | func scanPrompt(prompt string) []PromptToken { 11 | var tokens []PromptToken 12 | rawStart := 0 13 | 14 | flushRaw := func(end int) { 15 | if end <= rawStart { 16 | return 17 | } 18 | tokens = append(tokens, NewRawToken(prompt[rawStart:end])) 19 | rawStart = end 20 | } 21 | 22 | isWS := func(b byte) bool { 23 | return b == ' ' || b == '\n' || b == '\t' || b == '\r' 24 | } 25 | isWordStart := func(i int) bool { 26 | if i == 0 { 27 | return true 28 | } 29 | return isWS(prompt[i-1]) 30 | } 31 | 32 | for i := 0; i < len(prompt); i++ { 33 | b := prompt[i] 34 | if !isWordStart(i) { 35 | continue 36 | } 37 | 38 | if b != '@' && b != '$' { 39 | continue 40 | } 41 | 42 | flushRaw(i) 43 | 44 | start := i 45 | j := i 46 | for j < len(prompt) && !isWS(prompt[j]) { 47 | j++ 48 | } 49 | 50 | lit := prompt[start:j] 51 | if b == '@' { 52 | tokens = append(tokens, NewAtToken(lit)) 53 | } else { 54 | tokens = append(tokens, NewDollarToken(lit)) 55 | } 56 | 57 | i = j - 1 58 | rawStart = j 59 | } 60 | 61 | flushRaw(len(prompt)) 62 | return tokens 63 | } 64 | -------------------------------------------------------------------------------- /aic/token_dollar.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | type DollarToken struct { 10 | TokenCtx 11 | literal string // includes leading "$" 12 | } 13 | 14 | func NewDollarToken(lit string) PromptToken { 15 | return &DollarToken{literal: lit} 16 | } 17 | 18 | func (t *DollarToken) Type() PromptTokenType { return PromptTokenDollar } 19 | func (t *DollarToken) Literal() string { return t.literal } 20 | 21 | func (t *DollarToken) Value() string { 22 | return strings.TrimPrefix(t.literal, "$") 23 | } 24 | 25 | func (t *DollarToken) Validate(d *AiDir) error { 26 | // No validation rules yet. (Still a real token.) 27 | return nil 28 | } 29 | 30 | func (t *DollarToken) AfterValidate(r *PromptReader, index int) error { 31 | t.bind(r, index) 32 | return nil 33 | } 34 | 35 | func (t *DollarToken) Render(d *AiDir) (string, error) { 36 | // System command: $CLEAR 37 | // When present in prompt.md, it resets prompt.md to just the header. 38 | if t.Value() == "CLEAR" { 39 | if d == nil { 40 | return "", fmt.Errorf("$CLEAR: missing ai dir") 41 | } 42 | path := d.PromptPath() 43 | if path == "" { 44 | return "", fmt.Errorf("$CLEAR: missing prompt path") 45 | } 46 | 47 | // Overwrite prompt.md with the header only. 48 | if err := os.WriteFile(path, []byte(promptHeader), 0o644); err != nil { 49 | return "", fmt.Errorf("$CLEAR: write prompt.md: %w", err) 50 | } 51 | 52 | // Do not render the token into the output. 53 | return "", nil 54 | } 55 | 56 | // Default: render literally as typed. 57 | return t.literal, nil 58 | } 59 | 60 | func (t *DollarToken) String() string { 61 | return fmt.Sprintf("<%s: %q>", t.Type().String(), t.literal) 62 | } 63 | -------------------------------------------------------------------------------- /aic/file_collect.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | func CollectReadableFiles(targetAbs string, d *AiDir) ([]string, error) { 12 | info, err := os.Stat(targetAbs) 13 | if err != nil { 14 | return nil, fmt.Errorf("stat target %s: %w", targetAbs, err) 15 | } 16 | 17 | // If a file, just return it (unless ignored) 18 | if !info.IsDir() { 19 | if shouldIgnoreAbs(targetAbs, d) { 20 | return []string{}, nil 21 | } 22 | return []string{filepath.Clean(targetAbs)}, nil 23 | } 24 | 25 | // Directory walk 26 | var files []string 27 | err = filepath.WalkDir(targetAbs, func(path string, de os.DirEntry, walkErr error) error { 28 | if walkErr != nil { 29 | return walkErr 30 | } 31 | 32 | name := de.Name() 33 | 34 | // Always skip .git 35 | if de.IsDir() && name == ".git" { 36 | return filepath.SkipDir 37 | } 38 | 39 | // Skip ignored directories early 40 | if de.IsDir() { 41 | if shouldIgnoreAbs(path, d) { 42 | return filepath.SkipDir 43 | } 44 | return nil 45 | } 46 | 47 | // Skip ignored files 48 | if shouldIgnoreAbs(path, d) { 49 | return nil 50 | } 51 | 52 | files = append(files, filepath.Clean(path)) 53 | return nil 54 | }) 55 | if err != nil { 56 | return nil, fmt.Errorf("walk %s: %w", targetAbs, err) 57 | } 58 | 59 | sort.Strings(files) 60 | return files, nil 61 | } 62 | 63 | func shouldIgnoreAbs(abs string, d *AiDir) bool { 64 | if d == nil || d.WorkingDir == "" { 65 | return false 66 | } 67 | 68 | rel, err := filepath.Rel(d.WorkingDir, abs) 69 | if err != nil { 70 | return false 71 | } 72 | rel = filepath.ToSlash(rel) 73 | 74 | // If it escapes working dir, ignore (safety) 75 | if rel == ".." || strings.HasPrefix(rel, "../") { 76 | return true 77 | } 78 | 79 | // Never include ./ai itself when expanding @. unless user explicitly points there 80 | // (still allow reading it if targetAbs is inside ai, but default ignore patterns may cover) 81 | // We'll not hard-block it here. 82 | 83 | if d.Ignore == nil { 84 | return false 85 | } 86 | return d.Ignore.Match(rel) 87 | } 88 | -------------------------------------------------------------------------------- /aic/prompt_reader.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type PromptReader struct { 9 | Text string 10 | Tokens []PromptToken 11 | } 12 | 13 | func NewPromptReader(text string) *PromptReader { 14 | toks := TokenizePrompt(text) 15 | return &PromptReader{ 16 | Text: text, 17 | Tokens: toks, 18 | } 19 | } 20 | 21 | // ValidateOrDowngrade runs validation for each token using AiDir context. 22 | // If validation fails, token becomes Raw with same literal. 23 | func (p *PromptReader) ValidateOrDowngrade(d *AiDir) { 24 | if len(p.Tokens) == 0 { 25 | return 26 | } 27 | out := make([]PromptToken, 0, len(p.Tokens)) 28 | for _, tok := range p.Tokens { 29 | if err := tok.Validate(d); err != nil { 30 | out = append(out, NewRawToken(tok.Literal())) 31 | continue 32 | } 33 | out = append(out, tok) 34 | } 35 | p.Tokens = out 36 | } 37 | 38 | // BindTokens attaches this PromptReader + index to each token via AfterValidate. 39 | func (p *PromptReader) BindTokens() { 40 | for i, tok := range p.Tokens { 41 | _ = tok.AfterValidate(p, i) 42 | } 43 | } 44 | 45 | // Render iterates tokens and concatenates each token's rendered output. 46 | func (p *PromptReader) Render(d *AiDir) (string, error) { 47 | var sb strings.Builder 48 | for _, tok := range p.Tokens { 49 | part, err := tok.Render(d) 50 | if err != nil { 51 | return "", err 52 | } 53 | sb.WriteString(part) 54 | } 55 | return sb.String(), nil 56 | } 57 | 58 | func (p *PromptReader) RemoveToken(i int) bool { 59 | if i < 0 || i >= len(p.Tokens) { 60 | return false 61 | } 62 | p.Tokens = append(p.Tokens[:i], p.Tokens[i+1:]...) 63 | p.BindTokens() 64 | return true 65 | } 66 | 67 | func (p *PromptReader) InsertToken(i int, tok PromptToken) { 68 | if i < 0 { 69 | i = 0 70 | } 71 | if i > len(p.Tokens) { 72 | i = len(p.Tokens) 73 | } 74 | p.Tokens = append(p.Tokens[:i], append([]PromptToken{tok}, p.Tokens[i:]...)...) 75 | p.BindTokens() 76 | } 77 | 78 | func (p *PromptReader) ReplaceToken(i int, tok PromptToken) bool { 79 | if i < 0 || i >= len(p.Tokens) { 80 | return false 81 | } 82 | p.Tokens[i] = tok 83 | p.BindTokens() 84 | return true 85 | } 86 | 87 | func (p *PromptReader) String() string { 88 | if len(p.Tokens) == 0 { 89 | return "" 90 | } 91 | var sb strings.Builder 92 | for _, tok := range p.Tokens { 93 | sb.WriteString(tok.Literal()) 94 | } 95 | return sb.String() 96 | } 97 | 98 | func (p *PromptReader) Print() { 99 | fmt.Print(p.String() + "\n") 100 | } 101 | -------------------------------------------------------------------------------- /aic/gitignore.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | type GitIgnore struct { 11 | workingDir string 12 | patterns []string 13 | } 14 | 15 | func LoadGitIgnore(workingDir string) (*GitIgnore, error) { 16 | path := filepath.Join(workingDir, ".gitignore") 17 | f, err := os.Open(path) 18 | if err != nil { 19 | // Missing .gitignore is fine 20 | if os.IsNotExist(err) { 21 | return &GitIgnore{workingDir: workingDir}, nil 22 | } 23 | return nil, err 24 | } 25 | defer f.Close() 26 | 27 | var pats []string 28 | sc := bufio.NewScanner(f) 29 | for sc.Scan() { 30 | line := strings.TrimSpace(sc.Text()) 31 | if line == "" || strings.HasPrefix(line, "#") { 32 | continue 33 | } 34 | // NOTE: We intentionally do not implement negation (!) yet. 35 | // You can add it later if you want. 36 | pats = append(pats, line) 37 | } 38 | if err := sc.Err(); err != nil { 39 | return nil, err 40 | } 41 | 42 | return &GitIgnore{ 43 | workingDir: workingDir, 44 | patterns: pats, 45 | }, nil 46 | } 47 | 48 | // Match checks whether a project-relative POSIX path (e.g. "src/main.go") is ignored. 49 | func (g *GitIgnore) Match(relSlash string) bool { 50 | if g == nil { 51 | return false 52 | } 53 | p := strings.TrimPrefix(relSlash, "./") 54 | p = strings.TrimPrefix(p, "/") 55 | 56 | // Always ignore .git regardless of patterns 57 | if p == ".git" || strings.HasPrefix(p, ".git/") { 58 | return true 59 | } 60 | 61 | for _, pat := range g.patterns { 62 | if pat == "" { 63 | continue 64 | } 65 | 66 | // Normalize pattern to slash 67 | pp := strings.ReplaceAll(strings.TrimSpace(pat), "\\", "/") 68 | 69 | // Directory pattern "build/" matches anything under it 70 | if strings.HasSuffix(pp, "/") { 71 | base := strings.TrimSuffix(pp, "/") 72 | if p == base || strings.HasPrefix(p, base+"/") { 73 | return true 74 | } 75 | continue 76 | } 77 | 78 | // If pattern contains no slash, match against any segment basename 79 | if !strings.Contains(pp, "/") { 80 | // Exact basename match 81 | if filepath.Base(p) == pp { 82 | return true 83 | } 84 | // Glob basename match 85 | if ok, _ := filepath.Match(pp, filepath.Base(p)); ok { 86 | return true 87 | } 88 | continue 89 | } 90 | 91 | // Pattern has slashes: match against whole relative path (slash form) 92 | // Support simple globbing. 93 | if ok, _ := filepath.Match(pp, p); ok { 94 | return true 95 | } 96 | // Also allow patterns like "src/**" by treating trailing "/**" as prefix 97 | if strings.HasSuffix(pp, "/**") { 98 | base := strings.TrimSuffix(pp, "/**") 99 | if p == base || strings.HasPrefix(p, base+"/") { 100 | return true 101 | } 102 | } 103 | } 104 | 105 | return false 106 | } 107 | -------------------------------------------------------------------------------- /aic/token_at.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | type AtToken struct { 11 | TokenCtx 12 | literal string // includes leading "@" 13 | 14 | // resolved absolute target after Validate() 15 | targetAbs string 16 | } 17 | 18 | func NewAtToken(lit string) PromptToken { 19 | return &AtToken{literal: lit} 20 | } 21 | 22 | func (t *AtToken) Type() PromptTokenType { return PromptTokenAt } 23 | func (t *AtToken) Literal() string { return t.literal } 24 | 25 | func (t *AtToken) Value() string { 26 | return strings.TrimPrefix(t.literal, "@") 27 | } 28 | 29 | func (t *AtToken) Validate(d *AiDir) error { 30 | if d == nil || d.WorkingDir == "" { 31 | return fmt.Errorf("missing working directory") 32 | } 33 | 34 | val := strings.TrimSpace(t.Value()) 35 | if val == "" { 36 | return fmt.Errorf("empty @ token") 37 | } 38 | 39 | // @. means project root (working dir) 40 | if val == "." { 41 | t.targetAbs = d.WorkingDir 42 | return nil 43 | } 44 | 45 | // Disallow absolute paths; resolve relative to working dir. 46 | if filepath.IsAbs(val) { 47 | return fmt.Errorf("absolute paths not allowed: %s", val) 48 | } 49 | 50 | // Clean and ensure it cannot escape working dir. 51 | cleanRel := filepath.Clean(val) 52 | if cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) { 53 | return fmt.Errorf("path escapes working dir: %s", val) 54 | } 55 | 56 | abs := filepath.Join(d.WorkingDir, cleanRel) 57 | abs = filepath.Clean(abs) 58 | 59 | // Ensure still under working dir after clean. 60 | relToWd, err := filepath.Rel(d.WorkingDir, abs) 61 | if err != nil { 62 | return fmt.Errorf("resolve path: %w", err) 63 | } 64 | if relToWd == ".." || strings.HasPrefix(relToWd, ".."+string(os.PathSeparator)) { 65 | return fmt.Errorf("path escapes working dir: %s", val) 66 | } 67 | 68 | info, err := os.Stat(abs) 69 | if err != nil { 70 | return fmt.Errorf("target not found: %s", abs) 71 | } 72 | _ = info // can be file or directory 73 | 74 | t.targetAbs = abs 75 | return nil 76 | } 77 | 78 | func (t *AtToken) AfterValidate(r *PromptReader, index int) error { 79 | t.bind(r, index) 80 | return nil 81 | } 82 | 83 | func (t *AtToken) Render(d *AiDir) (string, error) { 84 | // If Validate wasn't called, fallback safely. 85 | if t.targetAbs == "" { 86 | return t.literal, nil 87 | } 88 | 89 | files, err := CollectReadableFiles(t.targetAbs, d) 90 | if err != nil { 91 | return "", err 92 | } 93 | 94 | var sb strings.Builder 95 | stats := ReadStats{} 96 | 97 | for _, abs := range files { 98 | content, ok, rstats, rerr := ReadTextFile(abs) 99 | if rerr != nil { 100 | // hard fail; you can soften this later if you want partial output 101 | return "", rerr 102 | } 103 | if !ok { 104 | continue 105 | } 106 | 107 | sb.WriteString("FILE: ") 108 | sb.WriteString(abs) 109 | sb.WriteString("\n") 110 | sb.WriteString(content) 111 | if !strings.HasSuffix(content, "\n") { 112 | sb.WriteString("\n") 113 | } 114 | sb.WriteString("\n") 115 | 116 | stats.Files++ 117 | stats.Lines += rstats.Lines 118 | stats.Chars += rstats.Chars 119 | } 120 | 121 | sb.WriteString(fmt.Sprintf("read [%d files] [%d lines] [%d characters]", stats.Files, stats.Lines, stats.Chars)) 122 | sb.WriteString("\n") 123 | 124 | return sb.String(), nil 125 | } 126 | 127 | func (t *AtToken) String() string { 128 | return fmt.Sprintf("<%s: %q>", t.Type().String(), t.literal) 129 | } 130 | -------------------------------------------------------------------------------- /aic/ai_dir.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | type AiDir struct { 11 | Root string 12 | WorkingDir string 13 | 14 | Prompts string 15 | Skills string 16 | 17 | Ignore *GitIgnore 18 | } 19 | 20 | const promptHeader = `# LLM MODEL THIS IS MY PROMPT: 21 | ` 22 | 23 | func NewAiDir(force bool) (*AiDir, error) { 24 | wd, err := os.Getwd() 25 | if err != nil { 26 | return nil, fmt.Errorf("get working directory: %w", err) 27 | } 28 | 29 | workingAbs := wd 30 | rootAbs := filepath.Join(workingAbs, "ai") 31 | promptFile := filepath.Join(rootAbs, "prompt.md") 32 | 33 | promptsAbs := filepath.Join(rootAbs, "prompts") 34 | skillsAbs := filepath.Join(rootAbs, "skills") 35 | 36 | // Handle existing directory 37 | if info, statErr := os.Lstat(rootAbs); statErr == nil { 38 | if !info.IsDir() { 39 | return nil, fmt.Errorf("ai path exists but is not a directory: %s", rootAbs) 40 | } 41 | if !force { 42 | return nil, fmt.Errorf("ai dir already exists: %s", rootAbs) 43 | } 44 | if err := os.RemoveAll(rootAbs); err != nil { 45 | return nil, fmt.Errorf("remove existing ai dir: %w", err) 46 | } 47 | } else if !os.IsNotExist(statErr) { 48 | return nil, fmt.Errorf("stat ai dir: %w", statErr) 49 | } 50 | 51 | // Create ai root + subdirs 52 | if err := os.MkdirAll(rootAbs, 0o755); err != nil { 53 | return nil, fmt.Errorf("create directory %s: %w", rootAbs, err) 54 | } 55 | if err := os.MkdirAll(promptsAbs, 0o755); err != nil { 56 | return nil, fmt.Errorf("create directory %s: %w", promptsAbs, err) 57 | } 58 | if err := os.MkdirAll(skillsAbs, 0o755); err != nil { 59 | return nil, fmt.Errorf("create directory %s: %w", skillsAbs, err) 60 | } 61 | 62 | // Write prompt.md 63 | if err := os.WriteFile(promptFile, []byte(promptHeader), 0o644); err != nil { 64 | return nil, fmt.Errorf("write prompt.md: %w", err) 65 | } 66 | 67 | ign, _ := LoadGitIgnore(workingAbs) // ignore missing .gitignore 68 | 69 | return &AiDir{ 70 | Root: rootAbs, 71 | WorkingDir: workingAbs, 72 | Prompts: promptsAbs, 73 | Skills: skillsAbs, 74 | Ignore: ign, 75 | }, nil 76 | } 77 | 78 | func OpenAiDir() (*AiDir, error) { 79 | wd, err := os.Getwd() 80 | if err != nil { 81 | return nil, fmt.Errorf("get working directory: %w", err) 82 | } 83 | 84 | workingAbs := wd 85 | rootAbs := filepath.Join(workingAbs, "ai") 86 | 87 | info, statErr := os.Lstat(rootAbs) 88 | if statErr != nil { 89 | return nil, fmt.Errorf("ai dir not found: %s", rootAbs) 90 | } 91 | if !info.IsDir() { 92 | return nil, fmt.Errorf("ai path exists but is not a directory: %s", rootAbs) 93 | } 94 | 95 | promptsAbs := filepath.Join(rootAbs, "prompts") 96 | skillsAbs := filepath.Join(rootAbs, "skills") 97 | 98 | // Ensure subdirs exist (non-destructive) 99 | _ = os.MkdirAll(promptsAbs, 0o755) 100 | _ = os.MkdirAll(skillsAbs, 0o755) 101 | 102 | ign, _ := LoadGitIgnore(workingAbs) 103 | 104 | return &AiDir{ 105 | Root: rootAbs, 106 | WorkingDir: workingAbs, 107 | Prompts: promptsAbs, 108 | Skills: skillsAbs, 109 | Ignore: ign, 110 | }, nil 111 | } 112 | 113 | func (d *AiDir) PromptPath() string { 114 | return filepath.Join(d.Root, "prompt.md") 115 | } 116 | 117 | func (d *AiDir) PromptText() (string, error) { 118 | b, err := os.ReadFile(d.PromptPath()) 119 | if err != nil { 120 | if os.IsNotExist(err) { 121 | return promptHeader, nil 122 | } 123 | return "", fmt.Errorf("read prompt.md: %w", err) 124 | } 125 | s := string(b) 126 | s = strings.ReplaceAll(s, "\r\n", "\n") 127 | return s, nil 128 | } 129 | -------------------------------------------------------------------------------- /aic/cli.go: -------------------------------------------------------------------------------- 1 | package aic 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/atotto/clipboard" 15 | ) 16 | 17 | const Version = "0.0.1" 18 | 19 | type CLI struct { 20 | Out io.Writer 21 | Err io.Writer 22 | } 23 | 24 | func NewCLI() *CLI { 25 | return &CLI{ 26 | Out: os.Stdout, 27 | Err: os.Stderr, 28 | } 29 | } 30 | 31 | func (c *CLI) Run(args []string) error { 32 | if c.Out == nil { 33 | c.Out = os.Stdout 34 | } 35 | if c.Err == nil { 36 | c.Err = os.Stderr 37 | } 38 | if len(args) == 0 { 39 | return c.cmdDefault() 40 | } 41 | cmd := strings.TrimSpace(args[0]) 42 | sub := args[1:] 43 | switch cmd { 44 | case "help", "-h", "--help": 45 | topic := "" 46 | if len(sub) > 0 { 47 | topic = strings.TrimSpace(sub[0]) 48 | } 49 | c.printHelp(topic) 50 | return nil 51 | case "version", "-v", "--version": 52 | fmt.Fprintln(c.Out, Version) 53 | return nil 54 | case "init": 55 | return c.cmdInit(sub) 56 | case "watch": 57 | return c.cmdWatch(sub) 58 | default: 59 | fmt.Fprintf(c.Err, "Unknown command: %s\n\n", cmd) 60 | c.printHelp("") 61 | return fmt.Errorf("unknown command: %s", cmd) 62 | } 63 | } 64 | 65 | func (c *CLI) cmdDefault() error { 66 | aiDir, err := OpenAiDir() 67 | if err != nil { 68 | aiDir, err = NewAiDir(false) 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | 74 | out, err := c.renderPromptToClipboard(aiDir) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // Still print to stdout 80 | fmt.Fprint(c.Out, out) 81 | if !strings.HasSuffix(out, "\n") { 82 | fmt.Fprintln(c.Out) 83 | } 84 | 85 | // Small hint to stderr so stdout stays clean for piping 86 | fmt.Fprintln(c.Err, "[copied output to clipboard]") 87 | return nil 88 | } 89 | 90 | func (c *CLI) cmdWatch(args []string) error { 91 | fs := flag.NewFlagSet("watch", flag.ContinueOnError) 92 | fs.SetOutput(c.Err) 93 | 94 | // simple knobs if you ever want to tweak: 95 | poll := fs.Duration("poll", 200*time.Millisecond, "poll interval for file changes") 96 | debounce := fs.Duration("debounce", 350*time.Millisecond, "debounce window to treat changes as a single save") 97 | if err := fs.Parse(args); err != nil { 98 | return err 99 | } 100 | 101 | aiDir, err := OpenAiDir() 102 | if err != nil { 103 | aiDir, err = NewAiDir(false) 104 | if err != nil { 105 | return err 106 | } 107 | } 108 | 109 | promptPath := aiDir.PromptPath() 110 | 111 | // Ensure file exists (init writes it, but just in case) 112 | if _, err := os.Stat(promptPath); err != nil { 113 | return fmt.Errorf("prompt.md not found: %s", promptPath) 114 | } 115 | 116 | fmt.Fprintf(c.Err, "Watching: %s\n", promptPath) 117 | fmt.Fprintln(c.Err, "Press Ctrl+C to stop.") 118 | 119 | // Handle Ctrl+C / SIGTERM 120 | stop := make(chan os.Signal, 1) 121 | signal.Notify(stop, os.Interrupt, syscall.SIGTERM) 122 | defer signal.Stop(stop) 123 | 124 | // Track last observed mod time + size 125 | lastMod, lastSize, err := fileModSize(promptPath) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | // Debounce state 131 | var pending bool 132 | var pendingSince time.Time 133 | 134 | ticker := time.NewTicker(*poll) 135 | defer ticker.Stop() 136 | 137 | for { 138 | select { 139 | case <-stop: 140 | fmt.Fprintln(c.Err, "\nStopped.") 141 | return nil 142 | 143 | case <-ticker.C: 144 | mod, size, statErr := fileModSize(promptPath) 145 | if statErr != nil { 146 | // If it disappears temporarily during save, just keep polling. 147 | continue 148 | } 149 | 150 | changed := mod.After(lastMod) || size != lastSize 151 | if changed { 152 | // update baseline immediately, but mark pending 153 | lastMod = mod 154 | lastSize = size 155 | pending = true 156 | pendingSince = time.Now() 157 | continue 158 | } 159 | 160 | // If we saw changes recently, wait until file is stable for debounce window. 161 | if pending && time.Since(pendingSince) >= *debounce { 162 | pending = false 163 | 164 | out, rerr := c.renderPromptToClipboard(aiDir) 165 | if rerr != nil { 166 | fmt.Fprintf(c.Err, "render error: %v\n", rerr) 167 | continue 168 | } 169 | 170 | // Status only; keep stdout clean. 171 | fmt.Fprintf(c.Err, "updated clipboard [%d chars]\n", len(out)) 172 | } 173 | } 174 | } 175 | } 176 | 177 | func fileModSize(path string) (time.Time, int64, error) { 178 | info, err := os.Stat(path) 179 | if err != nil { 180 | return time.Time{}, 0, err 181 | } 182 | return info.ModTime(), info.Size(), nil 183 | } 184 | 185 | // renderPromptToClipboard reads prompt.md, tokenizes/validates/renders, and writes to clipboard. 186 | // Returns the rendered string. 187 | func (c *CLI) renderPromptToClipboard(aiDir *AiDir) (string, error) { 188 | text, err := aiDir.PromptText() 189 | if err != nil { 190 | return "", err 191 | } 192 | 193 | reader := NewPromptReader(text) 194 | reader.ValidateOrDowngrade(aiDir) 195 | reader.BindTokens() 196 | 197 | out, err := reader.Render(aiDir) 198 | if err != nil { 199 | return "", err 200 | } 201 | 202 | if err := clipboard.WriteAll(out); err != nil { 203 | return "", fmt.Errorf("copy to clipboard: %w", err) 204 | } 205 | 206 | return out, nil 207 | } 208 | 209 | func (c *CLI) printHelp(topic string) { 210 | switch topic { 211 | case "init": 212 | fmt.Fprintln(c.Out, `Usage: 213 | aic init [--force] 214 | Creates ./ai and writes ./ai/prompt.md (only). 215 | Options: 216 | --force Remove existing ./ai before creating it. 217 | `) 218 | return 219 | case "watch": 220 | fmt.Fprintln(c.Out, `Usage: 221 | aic watch [--poll DURATION] [--debounce DURATION] 222 | Watches ./ai/prompt.md for changes. On save (debounced), tokenizes and copies output to clipboard. 223 | Options: 224 | --poll Poll interval (default: 200ms) 225 | --debounce Stable window to consider file "saved" (default: 350ms) 226 | `) 227 | return 228 | case "help": 229 | fmt.Fprintln(c.Out, `Usage: 230 | aic help [command] 231 | Shows help for a command (or general help). 232 | `) 233 | return 234 | case "version": 235 | fmt.Fprintln(c.Out, `Usage: 236 | aic version 237 | Prints the CLI version. 238 | `) 239 | return 240 | case "": 241 | default: 242 | fmt.Fprintf(c.Out, "No detailed help for %q.\n\n", topic) 243 | } 244 | fmt.Fprintln(c.Out, `aic - minimal CLI 245 | Usage: 246 | aic [args] 247 | Commands: 248 | init Create ./ai with prompt.md only 249 | watch Watch ./ai/prompt.md and copy expanded output to clipboard on save 250 | help Show help (optionally for a command) 251 | version Print version 252 | Default: 253 | Running with no command prints the expanded prompt (./ai/prompt.md) and copies output to clipboard. 254 | Examples: 255 | aic 256 | aic watch 257 | aic watch --debounce 500ms 258 | aic init --force 259 | `) 260 | } 261 | 262 | func (c *CLI) cmdInit(args []string) error { 263 | fs := flag.NewFlagSet("init", flag.ContinueOnError) 264 | fs.SetOutput(c.Err) 265 | force := fs.Bool("force", false, "remove existing ./ai dir before creating it") 266 | if err := fs.Parse(args); err != nil { 267 | return err 268 | } 269 | aiDir, err := NewAiDir(*force) 270 | if err != nil { 271 | return err 272 | } 273 | relRoot := aiDir.Root 274 | if wd, werr := os.Getwd(); werr == nil { 275 | if rel, rerr := filepath.Rel(wd, aiDir.Root); rerr == nil { 276 | relRoot = "." + string(os.PathSeparator) + rel 277 | } 278 | } 279 | fmt.Fprintln(c.Out, "Initialized:", relRoot) 280 | fmt.Fprintln(c.Out, " prompt:", filepath.Join(aiDir.Root, "prompt.md")) 281 | fmt.Fprintln(c.Out, " skills:", aiDir.Skills) 282 | return nil 283 | } 284 | --------------------------------------------------------------------------------