Skip to content

Commit d9498f8

Browse files
AnnatarHeclaude
andauthored
feat(cli): send rich context with shelltime q (#315)
* fix(model): classify compound commands for auto-run ClassifyCommand only looked at the first word, so with ai.agent.view enabled `curl ... | sh`, `ls | xargs rm -rf` or `cat a; rm -rf ~` were auto-run as "view". `shelltime q` is about to send repository-controlled text (file names, commit subjects, script names) to the model, so the classifier has to be safe against a manipulated suggestion. - Split pipelines, lists and substitutions (quote and escape aware) and return the most severe segment. - Treat interpreters and wrappers (sh, python, xargs, sudo, eval, env with a command, find -exec, ...) and multi-line scripts as "other", which never auto-runs. - Upgrade view commands that redirect output to a file, and downloads (curl -o, wget), to "edit". - Classify git, docker/podman, kubectl and systemctl by subcommand: read-only subcommands stay "view", `git reset --hard`, forced pushes, branch/stash deletion and `kubectl delete` are "delete", and exec, reboot and global git options are "other". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012gybkwT4DrTkNF4wKq54JT * feat(cli): send rich context with shelltime q `shelltime q` only sent the shell, OS, working directory and hostname, so suggestions guessed at the repository, project tooling and machine. When ai.shareContext is enabled (the default) it now also sends a `context` object, collected concurrently within 500ms: - system: OS version, kernel, arch, CPU count, uptime, load average, root, SSH, container, multiplexer, terminal, timezone and local time (read from /proc and sysctl, no forks) - git: path in repo, branch, upstream, ahead/behind, staged, unstaged, untracked and conflicted counts, in-progress operation, remote hosts and the last 3 commit subjects; falls back to HEAD if `git status` is slow - project: types and package managers from manifest and lock files, walking up to the repo root for monorepos, plus package.json script, Makefile target and justfile recipe names - tools found on PATH, and up to 40 names from the current directory The shell is now taken from the parent process when it is a known shell, so a fish session started from a zsh login shell is reported as fish. `shelltime q --show-context "..."` prints the request without calling the AI. With ai.shareContext: false only shell, OS and query are sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012gybkwT4DrTkNF4wKq54JT * fix(model): stop global flags and odd justfile lines defeating q safety - classifyDocker, classifyKubectl and classifySystemctl read the first argument as the subcommand, so `kubectl -n prod delete pod web`, `docker --context x rm -f web` or `systemctl --force poweroff` fell through to "edit" and auto-ran with ai.agent.edit. A leading global option now classifies as "other" (never auto-run), as git already did. - ParseJustRecipes indexed fields[0] on lines made only of non-ASCII whitespace (\v, \f, NBSP), panicking inside a collector goroutine and crashing `shelltime q`. Skip such lines. - Recover panics in query context collectors so a collector bug can only drop that piece of context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012gybkwT4DrTkNF4wKq54JT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8dd39c commit d9498f8

25 files changed

Lines changed: 2527 additions & 114 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ shelltime codex install
8989

9090
| Command | Description |
9191
|---------|-------------|
92-
| `shelltime query "prompt"` | Ask AI for a suggested shell command |
92+
| `shelltime query "prompt"` | Ask AI for a suggested shell command, using context about your repo, project and machine (see [Query Context](docs/CONFIG.md#query-context)) |
9393
| `shelltime q "prompt"` | Alias for `shelltime query` |
9494
| `shelltime cc install` | Install Claude Code OTEL configuration into `~/.claude/settings.json` |
9595
| `shelltime cc uninstall` | Remove Claude Code OTEL configuration from `~/.claude/settings.json` |

‎commands/query.go‎

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,55 @@ package commands
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"log/slog"
78
"os"
89
"os/exec"
910
"runtime"
1011
"strings"
12+
"time"
1113

1214
"github.com/gookit/color"
1315
"github.com/malamtime/cli/model"
1416
"github.com/malamtime/cli/stloader"
1517
"github.com/urfave/cli/v2"
18+
"go.opentelemetry.io/otel/attribute"
1619
)
1720

1821
var QueryCommand *cli.Command = &cli.Command{
1922
Name: "query",
2023
Aliases: []string{"q"},
2124
Usage: "Query AI for command suggestions",
2225
Action: commandQuery,
26+
Flags: []cli.Flag{
27+
&cli.BoolFlag{
28+
Name: "show-context",
29+
Usage: "print the request (query and collected context) without calling the AI",
30+
},
31+
},
2332
Description: `Query AI for command suggestions based on your prompt.
2433
34+
Unless ai.shareContext is false, the request includes context about where
35+
you run it: working directory, git state, project scripts and package
36+
manager, installed tools, a directory listing and machine info. Your AI
37+
Context from shelltime.xyz settings is applied as well.
38+
2539
Examples:
2640
shelltime query "get the top 5 memory-using processes"
2741
shelltime q "find all files modified in the last 24 hours"
28-
shelltime q "show disk usage for current directory"`,
42+
shelltime q "show disk usage for current directory"
43+
shelltime q --show-context "run the tests"`,
2944
}
3045

3146
func commandQuery(c *cli.Context) error {
3247
ctx, span := commandTracer.Start(c.Context, "query")
3348
defer span.End()
3449

35-
// Check if AI service is initialized
36-
if aiService == nil {
50+
showContext := c.Bool("show-context")
51+
52+
// Check if AI service is initialized (a dry run never calls it)
53+
if aiService == nil && !showContext {
3754
color.Red.Println("AI service is not configured")
3855
return fmt.Errorf("AI service is not available")
3956
}
@@ -59,18 +76,32 @@ func commandQuery(c *cli.Context) error {
5976
Token: cfg.Token,
6077
}
6178

79+
var l *stloader.Loader
80+
if !showContext {
81+
l = stloader.NewLoader(stloader.LoaderConfig{
82+
Text: "Collecting context...",
83+
EnableShining: true,
84+
BaseColor: stloader.RGB{R: 100, G: 180, B: 255},
85+
})
86+
l.Start()
87+
}
88+
6289
// Get system context
6390
systemContext, err := getSystemContext(query, cfg.AI)
6491
if err != nil {
6592
slog.Warn("Failed to get system context", slog.Any("err", err))
6693
}
94+
if shareContextEnabled(cfg.AI) {
95+
start := time.Now()
96+
systemContext.Context = gatherQueryContextFn(ctx, systemContext.Pwd)
97+
span.SetAttributes(attribute.Int64("query.context_ms", time.Since(start).Milliseconds()))
98+
}
6799

68-
l := stloader.NewLoader(stloader.LoaderConfig{
69-
Text: "Querying AI...",
70-
EnableShining: true,
71-
BaseColor: stloader.RGB{R: 100, G: 180, B: 255},
72-
})
73-
l.Start()
100+
if showContext {
101+
return printQueryRequest(systemContext)
102+
}
103+
104+
l.UpdateText("Querying AI...")
74105

75106
var result strings.Builder
76107
firstToken := true
@@ -156,6 +187,25 @@ func commandQuery(c *cli.Context) error {
156187
return nil
157188
}
158189

190+
// printQueryRequest prints the request body `shelltime q` would send.
191+
func printQueryRequest(vars model.CommandSuggestVariables) error {
192+
enc := json.NewEncoder(os.Stdout)
193+
enc.SetEscapeHTML(false)
194+
enc.SetIndent("", " ")
195+
if err := enc.Encode(vars); err != nil {
196+
return fmt.Errorf("failed to encode request: %w", err)
197+
}
198+
// On stderr so the JSON on stdout can be piped (e.g. into jq)
199+
fmt.Fprintln(os.Stderr, color.Gray.Sprint("Your AI Context from shelltime.xyz settings is added by the server."))
200+
return nil
201+
}
202+
203+
// shareContextEnabled reports whether `shelltime q` may send identifying
204+
// context. It defaults to true when ai.shareContext is unset.
205+
func shareContextEnabled(ai *model.AIConfig) bool {
206+
return ai == nil || ai.ShareContext == nil || *ai.ShareContext
207+
}
208+
159209
func shouldShowTips(cfg model.ShellTimeConfig) bool {
160210
// If ShowTips is not set (nil), default to true
161211
if cfg.AI == nil || cfg.AI.ShowTips == nil {
@@ -189,30 +239,16 @@ func executeCommand(ctx context.Context, command string) error {
189239
}
190240

191241
func getSystemContext(query string, ai *model.AIConfig) (model.CommandSuggestVariables, error) {
192-
// Get shell information
193-
shell := os.Getenv("SHELL")
194-
if shell == "" {
195-
shell = "unknown"
196-
} else {
197-
// Extract just the shell name from path
198-
if idx := strings.LastIndex(shell, "/"); idx >= 0 {
199-
shell = shell[idx+1:]
200-
}
201-
}
202-
203-
// Get OS information
204-
osInfo := runtime.GOOS
205-
206242
vars := model.CommandSuggestVariables{
207-
Shell: shell,
208-
Os: osInfo,
243+
Shell: currentShell(),
244+
Os: runtime.GOOS,
209245
Query: query,
210246
}
211247

212248
// Skip context fields when the user has opted out via config:
213-
// [ai]
214-
// shareContext = false
215-
if ai != nil && ai.ShareContext != nil && !*ai.ShareContext {
249+
// ai:
250+
// shareContext: false
251+
if !shareContextEnabled(ai) {
216252
return vars, nil
217253
}
218254

0 commit comments

Comments
 (0)