syntaur 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syntaur",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Project workflow CLI with dashboard, Claude Code plugin, and Codex plugin",
5
5
  "homepage": "https://github.com/prong-horn/syntaur#readme",
6
6
  "repository": {
@@ -54,6 +54,7 @@
54
54
  "node": ">=20.0.0"
55
55
  },
56
56
  "dependencies": {
57
+ "@inquirer/prompts": "^8.4.2",
57
58
  "better-sqlite3": "^11.0.0",
58
59
  "chokidar": "^4.0.0",
59
60
  "commander": "^13.0.0",
@@ -1,62 +1,102 @@
1
1
  #!/usr/bin/env bash
2
2
  # Syntaur statusline for Claude Code.
3
3
  #
4
- # Reads JSON from stdin per Claude Code's statusLine contract and prints a
5
- # single line containing:
6
- # [optional: output from a wrapped user script]
7
- # <git repo:branch>
8
- # <active syntaur assignment>
9
- # <sessionId suffix>
4
+ # Reads JSON from stdin per Claude Code's statusLine contract and renders a
5
+ # user-configurable single line composed of the segments listed in
6
+ # $HOME/.syntaur/statusline.config.json
7
+ # with shape:
8
+ # { "segments": ["wrap","git","assignment","model","ctx","session"],
9
+ # "separator": " · ",
10
+ # "wrap": "/optional/path/to/inner-statusline.sh" }
10
11
  #
11
- # Wrapping: if SYNTAUR_STATUSLINE_WRAP names an executable, or the first
12
- # non-empty line of $HOME/.syntaur/statusline.conf is a path to an executable,
13
- # that script is invoked first with the same stdin; its stdout becomes the
14
- # leading segment. This lets users who already had a custom statusline keep
15
- # it while composing syntaur's extra info on the right.
12
+ # Available segments:
13
+ # wrap stdout of an external script (composes another statusline)
14
+ # git repo:branch (+ dirty marker + ahead/behind counts)
15
+ # assignment active syntaur assignment (project/slug or standalone/uuid title)
16
+ # session Claude session id, last 8 chars prefixed by "…"
17
+ # model Claude model display name
18
+ # ctx context window fill bar, e.g. "ctx:[####------] 42%"
19
+ # cwd basename of the current working directory
16
20
  #
21
+ # If the config file is absent, falls back to a sensible default set.
17
22
  # Never fails the terminal — always exits 0.
18
23
 
19
24
  set -o pipefail 2>/dev/null || true
20
25
 
21
26
  INPUT=$(cat)
22
27
 
23
- # Degrade cleanly if jq is unavailable. Emit just the marker so users notice.
24
28
  if ! command -v jq >/dev/null 2>&1; then
25
29
  printf '%s' '(syntaur: jq missing)'
26
30
  exit 0
27
31
  fi
28
32
 
33
+ CONFIG_FILE="$HOME/.syntaur/statusline.config.json"
34
+ # Fall back to a simple one-line conf file for backward compat with earlier
35
+ # install-statusline versions that only stored a wrap target.
36
+ LEGACY_CONF="$HOME/.syntaur/statusline.conf"
37
+
38
+ # --- Load config ---
39
+ SEGMENTS_RAW=""
40
+ SEPARATOR=" · "
41
+ WRAP_PATH=""
42
+
43
+ if [ -f "$CONFIG_FILE" ]; then
44
+ SEGMENTS_RAW=$(jq -r '(.segments // []) | join(",")' "$CONFIG_FILE" 2>/dev/null)
45
+ SEP_FROM_CONF=$(jq -r '.separator // empty' "$CONFIG_FILE" 2>/dev/null)
46
+ [ -n "$SEP_FROM_CONF" ] && SEPARATOR="$SEP_FROM_CONF"
47
+ WRAP_PATH=$(jq -r '.wrap // empty' "$CONFIG_FILE" 2>/dev/null)
48
+ fi
49
+
50
+ # Env var always takes precedence for wrap (useful for testing).
51
+ [ -n "$SYNTAUR_STATUSLINE_WRAP" ] && WRAP_PATH="$SYNTAUR_STATUSLINE_WRAP"
52
+
53
+ # Legacy conf: first non-empty, non-comment line is wrap path.
54
+ if [ -z "$WRAP_PATH" ] && [ -f "$LEGACY_CONF" ]; then
55
+ WRAP_PATH=$(awk 'NF && !/^#/{print; exit}' "$LEGACY_CONF" 2>/dev/null)
56
+ fi
57
+
58
+ # Default segment set if none configured.
59
+ if [ -z "$SEGMENTS_RAW" ]; then
60
+ # Default: include wrap as leading segment only if a wrap path is set.
61
+ if [ -n "$WRAP_PATH" ]; then
62
+ SEGMENTS_RAW="wrap,git,assignment,session"
63
+ else
64
+ SEGMENTS_RAW="git,assignment,session"
65
+ fi
66
+ fi
67
+
68
+ # --- Extract stdin fields ---
29
69
  SESSION_ID=""
30
70
  CWD=""
71
+ MODEL=""
72
+ USED_PCT=""
31
73
 
32
74
  if [ -n "$INPUT" ]; then
33
75
  SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
34
76
  CWD=$(printf '%s' "$INPUT" | jq -r '.workspace.current_dir // .cwd // empty' 2>/dev/null)
77
+ MODEL=$(printf '%s' "$INPUT" | jq -r '.model.display_name // empty' 2>/dev/null)
78
+ USED_PCT=$(printf '%s' "$INPUT" | jq -r '.context_window.used_percentage // empty' 2>/dev/null)
35
79
  fi
36
80
 
37
81
  [ -z "$CWD" ] && CWD="$PWD"
38
82
 
39
- # --- Optional wrap: compose with an existing user script ---
40
- WRAP_PATH="${SYNTAUR_STATUSLINE_WRAP:-}"
41
- if [ -z "$WRAP_PATH" ] && [ -f "$HOME/.syntaur/statusline.conf" ]; then
42
- # First non-empty, non-comment line is the wrap path.
43
- WRAP_PATH=$(awk 'NF && !/^#/{print; exit}' "$HOME/.syntaur/statusline.conf" 2>/dev/null)
44
- fi
45
- WRAPPED_OUT=""
83
+ # --- Compute each available segment value (cheap, unconditional). ---
84
+
85
+ # wrap
86
+ WRAP_SEG=""
46
87
  if [ -n "$WRAP_PATH" ] && [ -r "$WRAP_PATH" ]; then
47
- # Run the wrapped script with the same stdin and a 2-second timeout (best-effort).
48
88
  if command -v timeout >/dev/null 2>&1; then
49
- WRAPPED_OUT=$(printf '%s' "$INPUT" | timeout 2 bash "$WRAP_PATH" 2>/dev/null)
89
+ WRAP_SEG=$(printf '%s' "$INPUT" | timeout 2 bash "$WRAP_PATH" 2>/dev/null)
50
90
  else
51
- WRAPPED_OUT=$(printf '%s' "$INPUT" | bash "$WRAP_PATH" 2>/dev/null)
91
+ WRAP_SEG=$(printf '%s' "$INPUT" | bash "$WRAP_PATH" 2>/dev/null)
52
92
  fi
53
- # Collapse any trailing newlines so the composed line stays single-row.
54
- WRAPPED_OUT=$(printf '%s' "$WRAPPED_OUT" | tr -d '\r' | awk 'NF{line=$0} END{print line}' 2>/dev/null)
93
+ # Take last non-empty line (collapse to single row).
94
+ WRAP_SEG=$(printf '%s' "$WRAP_SEG" | tr -d '\r' | awk 'NF{line=$0} END{print line}' 2>/dev/null)
55
95
  fi
56
96
 
57
- # --- Segment: git repo:branch ---
97
+ # git repo:branch[*] +ahead -behind
58
98
  GIT_SEG=""
59
- if [ -n "$CWD" ] && [ -d "$CWD" ]; then
99
+ if [ -d "$CWD" ]; then
60
100
  GIT_ROOT=$(git --no-optional-locks -C "$CWD" rev-parse --show-toplevel 2>/dev/null)
61
101
  if [ -n "$GIT_ROOT" ]; then
62
102
  REPO=$(basename "$GIT_ROOT")
@@ -65,34 +105,47 @@ if [ -n "$CWD" ] && [ -d "$CWD" ]; then
65
105
  SHORT=$(git --no-optional-locks -C "$CWD" rev-parse --short HEAD 2>/dev/null)
66
106
  [ -n "$SHORT" ] && BRANCH="detached@$SHORT"
67
107
  fi
108
+
109
+ DIRTY=""
110
+ if ! git --no-optional-locks -C "$CWD" diff --quiet 2>/dev/null \
111
+ || ! git --no-optional-locks -C "$CWD" diff --cached --quiet 2>/dev/null; then
112
+ DIRTY="*"
113
+ fi
114
+
115
+ AHEAD_BEHIND=""
116
+ UPSTREAM=$(git --no-optional-locks -C "$CWD" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null)
117
+ if [ -n "$UPSTREAM" ]; then
118
+ AHEAD=$(git --no-optional-locks -C "$CWD" rev-list --count "$UPSTREAM..HEAD" 2>/dev/null)
119
+ BEHIND=$(git --no-optional-locks -C "$CWD" rev-list --count "HEAD..$UPSTREAM" 2>/dev/null)
120
+ [ "${AHEAD:-0}" -gt 0 ] 2>/dev/null && AHEAD_BEHIND=" +${AHEAD}"
121
+ [ "${BEHIND:-0}" -gt 0 ] 2>/dev/null && AHEAD_BEHIND="${AHEAD_BEHIND} -${BEHIND}"
122
+ fi
123
+
68
124
  if [ -n "$BRANCH" ]; then
69
- GIT_SEG="$REPO:$BRANCH"
125
+ GIT_SEG="${REPO}:${BRANCH}${DIRTY}${AHEAD_BEHIND}"
70
126
  else
71
127
  GIT_SEG="$REPO"
72
128
  fi
73
129
  fi
74
130
  fi
75
131
 
76
- # --- Segment: active syntaur assignment ---
132
+ # assignment project/slug title (or standalone/uuid-prefix — title)
77
133
  ASSIGNMENT_SEG=""
78
134
  CONTEXT_FILE="$CWD/.syntaur/context.json"
79
135
  if [ -f "$CONTEXT_FILE" ]; then
80
136
  PROJECT_SLUG=$(jq -r '.projectSlug // empty' "$CONTEXT_FILE" 2>/dev/null)
81
137
  ASSIGNMENT_SLUG=$(jq -r '.assignmentSlug // empty' "$CONTEXT_FILE" 2>/dev/null)
82
138
  ASSIGNMENT_DIR=$(jq -r '.assignmentDir // empty' "$CONTEXT_FILE" 2>/dev/null)
83
-
84
139
  TITLE=""
85
140
  if [ -n "$ASSIGNMENT_DIR" ] && [ -f "$ASSIGNMENT_DIR/assignment.md" ]; then
86
141
  TITLE=$(awk '/^title:/{sub(/^title:[[:space:]]*"?/,""); sub(/"?[[:space:]]*$/,""); print; exit}' "$ASSIGNMENT_DIR/assignment.md" 2>/dev/null)
87
142
  fi
88
-
89
143
  LABEL=""
90
144
  if [ -n "$PROJECT_SLUG" ] && [ -n "$ASSIGNMENT_SLUG" ]; then
91
145
  LABEL="$PROJECT_SLUG/$ASSIGNMENT_SLUG"
92
146
  elif [ -n "$ASSIGNMENT_SLUG" ]; then
93
147
  LABEL="standalone/${ASSIGNMENT_SLUG:0:8}"
94
148
  fi
95
-
96
149
  if [ -n "$LABEL" ] && [ -n "$TITLE" ]; then
97
150
  ASSIGNMENT_SEG="$LABEL — $TITLE"
98
151
  elif [ -n "$LABEL" ]; then
@@ -100,7 +153,7 @@ if [ -f "$CONTEXT_FILE" ]; then
100
153
  fi
101
154
  fi
102
155
 
103
- # --- Segment: session id suffix ---
156
+ # session last 8 chars
104
157
  SESSION_SEG=""
105
158
  if [ -n "$SESSION_ID" ]; then
106
159
  LEN=${#SESSION_ID}
@@ -111,23 +164,61 @@ if [ -n "$SESSION_ID" ]; then
111
164
  fi
112
165
  fi
113
166
 
114
- # --- Compose. Wrapped output (if any) leads; syntaur segments trail. ---
115
- SYNTAUR_PARTS=""
116
- for seg in "$GIT_SEG" "$ASSIGNMENT_SEG" "$SESSION_SEG"; do
117
- if [ -n "$seg" ]; then
118
- if [ -z "$SYNTAUR_PARTS" ]; then
119
- SYNTAUR_PARTS="$seg"
167
+ # model
168
+ MODEL_SEG=""
169
+ [ -n "$MODEL" ] && MODEL_SEG="$MODEL"
170
+
171
+ # ctx fill bar
172
+ CTX_SEG=""
173
+ if [ -n "$USED_PCT" ]; then
174
+ USED_INT=$(printf "%.0f" "$USED_PCT" 2>/dev/null || echo "$USED_PCT")
175
+ if [ -n "$USED_INT" ] && [ "$USED_INT" -ge 0 ] 2>/dev/null; then
176
+ FILLED=$(( USED_INT / 10 ))
177
+ [ "$FILLED" -gt 10 ] && FILLED=10
178
+ EMPTY=$(( 10 - FILLED ))
179
+ BAR=""
180
+ i=0
181
+ while [ "$i" -lt "$FILLED" ]; do BAR="${BAR}#"; i=$((i+1)); done
182
+ i=0
183
+ while [ "$i" -lt "$EMPTY" ]; do BAR="${BAR}-"; i=$((i+1)); done
184
+ CTX_SEG="ctx:[${BAR}] ${USED_INT}%"
185
+ fi
186
+ fi
187
+
188
+ # cwd — basename
189
+ CWD_SEG=""
190
+ [ -n "$CWD" ] && CWD_SEG=$(basename "$CWD")
191
+
192
+ # --- Emit the selected segments in order ---
193
+ OUT=""
194
+ # Split SEGMENTS_RAW on commas using IFS.
195
+ OLD_IFS="$IFS"
196
+ IFS=','
197
+ for name in $SEGMENTS_RAW; do
198
+ IFS="$OLD_IFS"
199
+ # Trim whitespace
200
+ name=$(printf '%s' "$name" | awk '{$1=$1; print}')
201
+ value=""
202
+ case "$name" in
203
+ wrap) value="$WRAP_SEG" ;;
204
+ git) value="$GIT_SEG" ;;
205
+ assignment) value="$ASSIGNMENT_SEG" ;;
206
+ session) value="$SESSION_SEG" ;;
207
+ model) value="$MODEL_SEG" ;;
208
+ ctx) value="$CTX_SEG" ;;
209
+ cwd) value="$CWD_SEG" ;;
210
+ *) value="" ;;
211
+ esac
212
+ if [ -n "$value" ]; then
213
+ if [ -z "$OUT" ]; then
214
+ OUT="$value"
120
215
  else
121
- SYNTAUR_PARTS="$SYNTAUR_PARTS · $seg"
216
+ OUT="${OUT}${SEPARATOR}${value}"
122
217
  fi
123
218
  fi
219
+ IFS=','
124
220
  done
221
+ IFS="$OLD_IFS"
125
222
 
126
- if [ -n "$WRAPPED_OUT" ] && [ -n "$SYNTAUR_PARTS" ]; then
127
- printf '%s · %s' "$WRAPPED_OUT" "$SYNTAUR_PARTS"
128
- elif [ -n "$WRAPPED_OUT" ]; then
129
- printf '%s' "$WRAPPED_OUT"
130
- else
131
- printf '%s' "$SYNTAUR_PARTS"
132
- fi
223
+ printf '%s' "$OUT"
133
224
  exit 0