forge-cw 0.1.0

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/console/assets/addon-fit-BlxrFPDK.js +1 -0
  3. package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
  4. package/console/assets/index-Digctq9W.css +32 -0
  5. package/console/assets/index-ItVUTuQf.js +31 -0
  6. package/console/assets/xterm-DlVFs1Kw.js +9 -0
  7. package/console/index.html +13 -0
  8. package/cw/.bundle.json +4 -0
  9. package/cw/agents/presentation-curator.md +119 -0
  10. package/cw/agents/weather-agent.md +67 -0
  11. package/cw/cw +6188 -0
  12. package/cw/cw-shell-integration.sh +132 -0
  13. package/cw/hooks/HOOKS-README.md +471 -0
  14. package/cw/hooks/config/hooks-config.json +24 -0
  15. package/cw/hooks/scripts/auto-compact.py +133 -0
  16. package/cw/hooks/scripts/hooks.py +472 -0
  17. package/cw/hooks/scripts/review-autoclose.py +171 -0
  18. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
  19. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
  20. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
  21. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
  22. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
  23. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
  24. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
  25. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
  26. package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
  27. package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
  28. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
  29. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
  30. package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
  31. package/cw/hooks/sounds/configchange/configchange.wav +0 -0
  32. package/cw/hooks/sounds/notification/notification.mp3 +0 -0
  33. package/cw/hooks/sounds/notification/notification.wav +0 -0
  34. package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
  35. package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
  36. package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
  37. package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
  38. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
  39. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
  40. package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
  41. package/cw/hooks/sounds/precompact/precompact.wav +0 -0
  42. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
  43. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
  44. package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
  45. package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
  46. package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
  47. package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
  48. package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
  49. package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
  50. package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
  51. package/cw/hooks/sounds/setup/Setup.wav +0 -0
  52. package/cw/hooks/sounds/stop/stop.mp3 +0 -0
  53. package/cw/hooks/sounds/stop/stop.wav +0 -0
  54. package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
  55. package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
  56. package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
  57. package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
  58. package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
  59. package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
  60. package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
  61. package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
  62. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
  63. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
  64. package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
  65. package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
  66. package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
  67. package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
  68. package/cw/install.sh +243 -0
  69. package/cw/lib/context/github.sh +51 -0
  70. package/cw/lib/context/linear.sh +49 -0
  71. package/cw/lib/context/notion.sh +38 -0
  72. package/cw/lib/dashboard/activity-hook.py +134 -0
  73. package/cw/lib/dashboard/index.html +1515 -0
  74. package/cw/lib/dashboard/server.py +379 -0
  75. package/cw/lib/dashboard/start.sh +15 -0
  76. package/cw/lib/harnesses/claude.sh +92 -0
  77. package/cw/lib/harnesses/codex.sh +245 -0
  78. package/cw/lib/harnesses/opencode.sh +120 -0
  79. package/cw/lib/harnesses/pi.sh +66 -0
  80. package/cw/lib/iterm2.sh +140 -0
  81. package/cw/templates/CLAUDE.template.md +48 -0
  82. package/dist/index.js +4376 -0
  83. package/package.json +51 -0
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CW Review Auto-Close Hook (PostToolUse on Bash)
4
+ ================================================
5
+ Detects when Claude posts a review via `gh api` or `gh pr review`
6
+ and automatically closes the review session + tells Claude to stop.
7
+
8
+ Only triggers session close on APPROVE. REQUEST_CHANGES and COMMENT
9
+ leave the session open so the reviewer can continue iterating.
10
+
11
+ Reads CW_PROJECT, CW_TASK, CW_TASK_TYPE from environment (set by cmd_review).
12
+ """
13
+
14
+ import sys
15
+ import json
16
+ import os
17
+ import re
18
+ from datetime import datetime, timezone
19
+
20
+
21
+ def _debug(msg):
22
+ """Write debug info to a log file for troubleshooting."""
23
+ try:
24
+ cw_home = os.environ.get("CW_HOME", os.path.expanduser("~/.cw"))
25
+ log_path = os.path.join(cw_home, "review-autoclose.log")
26
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
27
+ with open(log_path, "a") as f:
28
+ f.write(f"{now} {msg}\n")
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ def main():
34
+ # Only act on review sessions
35
+ task_type = os.environ.get("CW_TASK_TYPE", "")
36
+ if task_type != "review":
37
+ sys.exit(0)
38
+
39
+ project = os.environ.get("CW_PROJECT", "")
40
+ task = os.environ.get("CW_TASK", "") # e.g. "pr-123"
41
+ if not project or not task:
42
+ _debug(f"SKIP: missing env — project={project!r} task={task!r}")
43
+ sys.exit(0)
44
+
45
+ # Read hook data from stdin
46
+ try:
47
+ stdin_content = sys.stdin.read().strip()
48
+ if not stdin_content:
49
+ sys.exit(0)
50
+ hook_data = json.loads(stdin_content)
51
+ except (json.JSONDecodeError, Exception) as e:
52
+ _debug(f"SKIP: stdin parse error — {e}")
53
+ sys.exit(0)
54
+
55
+ # Check the command submitted a review
56
+ tool_input = hook_data.get("tool_input", {})
57
+ command = tool_input.get("command", "")
58
+
59
+ is_review = False
60
+ review_event = ""
61
+
62
+ # Pattern 1: gh api ... /reviews ... event=<EVENT> (with -f or -F flags)
63
+ if "gh api" in command and "reviews" in command:
64
+ # Match: -f event=APPROVE, -F event=APPROVE, event=APPROVE
65
+ m = re.search(
66
+ r'event[=\s]+["\']?(APPROVE|REQUEST_CHANGES|COMMENT)["\']?',
67
+ command,
68
+ re.IGNORECASE,
69
+ )
70
+ if m:
71
+ is_review = True
72
+ review_event = m.group(1).upper()
73
+ else:
74
+ # Also match JSON body: "event": "APPROVE" or "event":"APPROVE"
75
+ m = re.search(
76
+ r'"event"\s*:\s*"(APPROVE|REQUEST_CHANGES|COMMENT)"',
77
+ command,
78
+ re.IGNORECASE,
79
+ )
80
+ if m:
81
+ is_review = True
82
+ review_event = m.group(1).upper()
83
+
84
+ # Pattern 2: gh pr review --approve / --request-changes / --comment
85
+ if "gh pr review" in command:
86
+ if "--approve" in command:
87
+ is_review = True
88
+ review_event = "APPROVE"
89
+ elif "--request-changes" in command:
90
+ is_review = True
91
+ review_event = "REQUEST_CHANGES"
92
+ elif "--comment" in command:
93
+ is_review = True
94
+ review_event = "COMMENT"
95
+
96
+ if not is_review:
97
+ _debug(f"SKIP: not a review command — {command[:200]}")
98
+ sys.exit(0)
99
+
100
+ _debug(f"DETECTED: {review_event} for {project} {task}")
101
+
102
+ # Only auto-close on APPROVE — REQUEST_CHANGES and COMMENT leave
103
+ # the session open so the reviewer can keep iterating.
104
+ if review_event != "APPROVE":
105
+ _debug(f"SKIP: not closing session — event is {review_event}, not APPROVE")
106
+ sys.exit(0)
107
+
108
+ # Check that the command succeeded
109
+ # tool_response has: stdout, stderr, interrupted — no exit_code field
110
+ tool_response = hook_data.get("tool_response", {})
111
+
112
+ # Guard: tool_response might be a string in some cases
113
+ if isinstance(tool_response, str):
114
+ tool_response = {"stdout": tool_response, "stderr": "", "interrupted": False}
115
+
116
+ stderr = tool_response.get("stderr", "")
117
+ stdout = tool_response.get("stdout", "")
118
+ interrupted = tool_response.get("interrupted", False)
119
+
120
+ # gh pr review outputs its success message to stderr (e.g. "✓ Approved pull request #334")
121
+ # so we can't treat stderr-only output as failure. Instead, only bail if interrupted
122
+ # or if stderr looks like an actual error (no success indicator and no stdout).
123
+ has_error = stderr and "error" in stderr.lower() and not stdout
124
+ if interrupted or has_error:
125
+ _debug(f"SKIP: command failed — interrupted={interrupted} has_error={has_error} stderr={stderr[:200]}")
126
+ sys.exit(0)
127
+
128
+ # ── Close the review session (same as cw review --done) ──────────
129
+ cw_home = os.environ.get("CW_HOME", os.path.expanduser("~/.cw"))
130
+ sessions_log = os.path.join(cw_home, "sessions.log")
131
+ session_dir = os.path.join(cw_home, "sessions", project, f"review-{task}")
132
+ session_meta = os.path.join(session_dir, "session.json")
133
+
134
+ # Update session.json
135
+ if os.path.isfile(session_meta):
136
+ try:
137
+ with open(session_meta) as f:
138
+ meta = json.load(f)
139
+ meta["status"] = "done"
140
+ meta["review_event"] = review_event
141
+ meta["closed"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
142
+ with open(session_meta, "w") as f:
143
+ json.dump(meta, f, indent=2)
144
+ _debug(f"CLOSED: session updated — {session_meta}")
145
+ except Exception as e:
146
+ _debug(f"ERROR: failed to update session — {e}")
147
+ else:
148
+ _debug(f"WARN: session.json not found at {session_meta}")
149
+
150
+ # Log to sessions.log
151
+ try:
152
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
153
+ with open(sessions_log, "a") as f:
154
+ f.write(f"{now} DONE {project} review={task}\n")
155
+ except Exception:
156
+ pass
157
+
158
+ # Tell Claude to stop — exit code 2 sends stderr as feedback
159
+ event_label = review_event.replace("_", " ").title()
160
+ print(
161
+ f"STOP. The review session for {project} PR #{task.replace('pr-', '')} "
162
+ f"has been automatically closed after {event_label}. "
163
+ "The session metadata and logs have been updated. "
164
+ "Do NOT continue working. Say a brief goodbye and stop.",
165
+ file=sys.stderr,
166
+ )
167
+ sys.exit(2)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
Binary file
Binary file
Binary file
Binary file
package/cw/install.sh ADDED
@@ -0,0 +1,243 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # CW Installer — Coding Workspace
5
+ # Usage: ./install.sh [--no-shell]
6
+ # or: curl -fsSL https://raw.githubusercontent.com/avarajar/cw/main/install.sh | bash
7
+ #
8
+ # --no-shell install without adding the shell integration to ~/.zshrc or
9
+ # ~/.bashrc (for installers such as Forge that call cw by path)
10
+
11
+ CW_HOME="${CW_HOME:-$HOME/.cw}"
12
+ REPO_URL="https://github.com/avarajar/cw"
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" 2>/dev/null)" && pwd 2>/dev/null || echo ".")"
14
+
15
+ NC='\033[0m'
16
+ BOLD='\033[1m'
17
+ C='\033[36m'
18
+ G='\033[32m'
19
+ Y='\033[33m'
20
+ R='\033[31m'
21
+
22
+ log() { echo -e "${C}[cw]${NC} $*"; }
23
+ ok() { echo -e "${G}[✓]${NC} $*"; }
24
+ warn() { echo -e "${Y}[!]${NC} $*"; }
25
+ err() { echo -e "${R}[✗]${NC} $*" >&2; }
26
+
27
+ # ── Check requirements ──────────────────────────────────────────────────────
28
+
29
+ check_requirements() {
30
+ local missing=()
31
+
32
+ if ! command -v git &>/dev/null; then
33
+ missing+=("git")
34
+ fi
35
+
36
+ if ! command -v python3 &>/dev/null; then
37
+ missing+=("python3")
38
+ fi
39
+
40
+ if ! command -v claude &>/dev/null; then
41
+ warn "Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code"
42
+ fi
43
+
44
+ if [[ ${#missing[@]} -gt 0 ]]; then
45
+ err "Missing required tools: ${missing[*]}"
46
+ exit 1
47
+ fi
48
+
49
+ # Check git version (need 2.15+ for worktrees)
50
+ local git_version
51
+ git_version=$(git --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
52
+ local major minor
53
+ major=$(echo "$git_version" | cut -d. -f1)
54
+ minor=$(echo "$git_version" | cut -d. -f2)
55
+ if [[ $major -lt 2 ]] || [[ $major -eq 2 && $minor -lt 15 ]]; then
56
+ err "Git 2.15+ required for worktree support. Current: $(git --version)"
57
+ exit 1
58
+ fi
59
+
60
+ # Check that `env bash` resolves to bash 4+. macOS ships /bin/bash 3.2,
61
+ # which breaks cw at runtime even if install.sh ran under a newer bash.
62
+ local env_bash_version
63
+ env_bash_version=$(/usr/bin/env bash -c 'echo "$BASH_VERSINFO"' 2>/dev/null || echo 0)
64
+ if [[ $env_bash_version -lt 4 ]]; then
65
+ err "cw requires bash >= 4, but \`env bash\` resolves to bash $env_bash_version on this system."
66
+ err "On macOS: brew install bash, then ensure /opt/homebrew/bin precedes /bin in PATH."
67
+ exit 1
68
+ fi
69
+
70
+ ok "Requirements met"
71
+ }
72
+
73
+ # ── Install ─────────────────────────────────────────────────────────────────
74
+
75
+ install_files() {
76
+ local source_dir="$SCRIPT_DIR"
77
+
78
+ # If running from curl pipe, clone to temp dir
79
+ if [[ ! -f "$source_dir/cw" ]]; then
80
+ log "Downloading CW..."
81
+ local tmp_dir
82
+ tmp_dir=$(mktemp -d)
83
+ git clone --depth 1 "$REPO_URL" "$tmp_dir" 2>/dev/null
84
+ source_dir="$tmp_dir"
85
+ fi
86
+
87
+ # Create directory structure
88
+ mkdir -p "$CW_HOME"/{bin,lib,accounts,sessions,templates/workflows,agents,commands,mcps,stacks}
89
+
90
+ # Copy scripts
91
+ cp "$source_dir/cw" "$CW_HOME/bin/cw"
92
+ chmod +x "$CW_HOME/bin/cw"
93
+
94
+ cp "$source_dir/cw-shell-integration.sh" "$CW_HOME/cw-shell-integration.sh"
95
+
96
+ # Copy lib
97
+ if [[ -d "$source_dir/lib" ]]; then
98
+ cp "$source_dir"/lib/*.sh "$CW_HOME/lib/" 2>/dev/null || true
99
+ # Copy harness drivers and context adapters
100
+ for sub in harnesses context; do
101
+ if [[ -d "$source_dir/lib/$sub" ]]; then
102
+ mkdir -p "$CW_HOME/lib/$sub"
103
+ cp "$source_dir"/lib/$sub/*.sh "$CW_HOME/lib/$sub/" 2>/dev/null || true
104
+ fi
105
+ done
106
+ # Copy dashboard
107
+ if [[ -d "$source_dir/lib/dashboard" ]]; then
108
+ mkdir -p "$CW_HOME/lib/dashboard"
109
+ cp "$source_dir"/lib/dashboard/{server.py,index.html,activity-hook.py,start.sh} "$CW_HOME/lib/dashboard/" 2>/dev/null || true
110
+ ok "Dashboard installed"
111
+ fi
112
+ fi
113
+
114
+ # Copy templates
115
+ cp "$source_dir/templates/CLAUDE.template.md" "$CW_HOME/templates/CLAUDE.template.md"
116
+
117
+ # Copy agents
118
+ if [[ -d "$source_dir/agents" ]]; then
119
+ mkdir -p "$CW_HOME/agents"
120
+ cp "$source_dir"/agents/*.md "$CW_HOME/agents/" 2>/dev/null || true
121
+ ok "Agents installed"
122
+ fi
123
+
124
+ # Copy hooks
125
+ if [[ -d "$source_dir/hooks" ]]; then
126
+ mkdir -p "$CW_HOME/hooks"
127
+ cp -r "$source_dir/hooks/." "$CW_HOME/hooks/"
128
+ ok "Hooks installed"
129
+ fi
130
+
131
+ # Copy MCP configs
132
+ if [[ -d "$source_dir/mcps" ]]; then
133
+ mkdir -p "$CW_HOME/mcps"
134
+ cp "$source_dir"/mcps/*.json "$CW_HOME/mcps/" 2>/dev/null || true
135
+ ok "MCP configs installed"
136
+ fi
137
+
138
+ # Initialize projects.json if not exists
139
+ [[ -f "$CW_HOME/projects.json" ]] || echo '{}' > "$CW_HOME/projects.json"
140
+
141
+ ok "Files installed to $CW_HOME"
142
+
143
+ # Cleanup temp dir if used. Must be `if` (not `[[ ]] && cmd`) because a
144
+ # false test on the function's last statement propagates exit 1 and
145
+ # `set -e` would abort the script before setup_shell runs.
146
+ if [[ -n "${tmp_dir:-}" && -d "$tmp_dir" ]]; then
147
+ rm -rf "$tmp_dir"
148
+ fi
149
+ }
150
+
151
+ # ── Shell integration ───────────────────────────────────────────────────────
152
+
153
+ setup_shell() {
154
+ local shell_line='[[ -f "$HOME/.cw/cw-shell-integration.sh" ]] && source "$HOME/.cw/cw-shell-integration.sh"'
155
+ local added=false
156
+
157
+ # Detect the user's actual shell
158
+ local user_shell
159
+ user_shell=$(basename "${SHELL:-/bin/zsh}")
160
+
161
+ # Prioritize the active shell's rc file
162
+ local rc_files=()
163
+ if [[ "$user_shell" == "zsh" ]]; then
164
+ rc_files=("$HOME/.zshrc" "$HOME/.bashrc")
165
+ else
166
+ rc_files=("$HOME/.bashrc" "$HOME/.zshrc")
167
+ fi
168
+
169
+ for rc in "${rc_files[@]}"; do
170
+ if [[ -f "$rc" ]]; then
171
+ if ! grep -q "cw-shell-integration" "$rc" 2>/dev/null; then
172
+ echo "" >> "$rc"
173
+ echo "# CW — Coding Workspace" >> "$rc"
174
+ echo "$shell_line" >> "$rc"
175
+ ok "Added to $rc"
176
+ added=true
177
+ else
178
+ ok "Already in $rc"
179
+ added=true
180
+ fi
181
+ fi
182
+ done
183
+
184
+ # If no rc file exists for the active shell, create it
185
+ if ! $added; then
186
+ local target_rc="$HOME/.${user_shell}rc"
187
+ echo "" >> "$target_rc"
188
+ echo "# CW — Coding Workspace" >> "$target_rc"
189
+ echo "$shell_line" >> "$target_rc"
190
+ ok "Created and added to $target_rc"
191
+ added=true
192
+ fi
193
+
194
+ # Verify PATH will work after sourcing
195
+ if ! echo "$PATH" | tr ':' '\n' | grep -q "$CW_HOME/bin"; then
196
+ echo ""
197
+ warn "PATH does not include ${C}$CW_HOME/bin${NC} yet."
198
+ echo -e " Run: ${C}source ~/.${user_shell}rc${NC} or restart your terminal."
199
+ fi
200
+ }
201
+
202
+ # ── Main ────────────────────────────────────────────────────────────────────
203
+
204
+ main() {
205
+ local shell_integration=true
206
+ while [[ $# -gt 0 ]]; do
207
+ case "$1" in
208
+ --no-shell) shell_integration=false ;;
209
+ *) err "Unknown option: $1"; echo "Usage: ./install.sh [--no-shell]" >&2; exit 1 ;;
210
+ esac
211
+ shift
212
+ done
213
+
214
+ echo ""
215
+ echo -e "${BOLD}CW — Coding Workspace${NC}"
216
+ echo -e "Installing to ${C}$CW_HOME${NC}"
217
+ echo ""
218
+
219
+ check_requirements
220
+ install_files
221
+ if $shell_integration; then
222
+ setup_shell
223
+ else
224
+ log "Skipped shell integration (--no-shell): run ${C}$CW_HOME/bin/cw${NC} by path"
225
+ fi
226
+
227
+ echo ""
228
+ ok "Installation complete!"
229
+ echo ""
230
+ echo -e " ${BOLD}Next steps:${NC}"
231
+ if $shell_integration; then
232
+ echo -e " 1. Restart your terminal (or: ${C}source ~/.zshrc${NC})"
233
+ else
234
+ echo -e " 1. Add ${C}$CW_HOME/bin${NC} to your PATH to run cw from a terminal"
235
+ fi
236
+ echo -e " 2. ${C}cw init${NC}"
237
+ echo -e " 3. ${C}cw account add work${NC}"
238
+ echo -e " 4. ${C}cw project register <path> --account work${NC}"
239
+ echo -e " 5. ${C}cw work <project> <task>${NC}"
240
+ echo ""
241
+ }
242
+
243
+ main "$@"
@@ -0,0 +1,51 @@
1
+ # github needs no cw credential; gh carries its own auth
2
+ context_credential_github() {
3
+ command -v gh >/dev/null 2>&1
4
+ }
5
+
6
+ # passes gh the canonical url so it never resolves the repo from the caller's cwd
7
+ context_fetch_github() {
8
+ local url="$1"
9
+ local re='^https?://(www\.)?github\.com/([^/[:space:]]+)/([^/[:space:]]+)/(issues|pull)/([0-9]+)([/?#].*)?$'
10
+ [[ "$url" =~ $re ]] || return 1
11
+ local owner="${BASH_REMATCH[2]}" repo="${BASH_REMATCH[3]}" path="${BASH_REMATCH[4]}" num="${BASH_REMATCH[5]}"
12
+ local kind=issue
13
+ [[ "$path" == pull ]] && kind=pr
14
+ local canonical="https://github.com/$owner/$repo/$path/$num"
15
+ local json
16
+ json=$(gh "$kind" view "$canonical" --json title,body,number,state,labels 2>/dev/null) || return 1
17
+ CW_GH_JSON="$json" CW_GH_NUM="$num" python3 - <<'PY'
18
+ import json, os, sys
19
+ try:
20
+ d = json.loads(os.environ["CW_GH_JSON"])
21
+ except Exception:
22
+ sys.exit(1)
23
+ # a response for a different number means gh answered a different question
24
+ if str(d.get("number")) != os.environ["CW_GH_NUM"]:
25
+ sys.exit(1)
26
+ labels = ", ".join(l["name"] for l in d.get("labels", []))
27
+ print(f"**#{d.get('number')} — {d.get('title','')}**")
28
+ print(f"State: {d.get('state','')}" + (f" · Labels: {labels}" if labels else ""))
29
+ print()
30
+ print(d.get("body") or "_No description._")
31
+ PY
32
+ }
33
+
34
+ # prints a pull request's head branch, asking gh with the canonical url
35
+ context_pr_branch_github() {
36
+ local url="$1"
37
+ local re='^https?://(www\.)?github\.com/([^/[:space:]]+)/([^/[:space:]]+)/pull/([0-9]+)([/?#].*)?$'
38
+ [[ "$url" =~ $re ]] || return 1
39
+ local canonical="https://github.com/${BASH_REMATCH[2]}/${BASH_REMATCH[3]}/pull/${BASH_REMATCH[4]}"
40
+ local json
41
+ json=$(gh pr view "$canonical" --json headRefName 2>/dev/null) || return 1
42
+ CW_GH_JSON="$json" python3 -c '
43
+ import json, os, sys
44
+ try:
45
+ branch = json.loads(os.environ["CW_GH_JSON"]).get("headRefName") or ""
46
+ except Exception:
47
+ sys.exit(1)
48
+ if not branch:
49
+ sys.exit(1)
50
+ print(branch, end="")'
51
+ }
@@ -0,0 +1,49 @@
1
+ # linear needs a personal api key from the env or ~/.cw/tokens.env
2
+ context_credential_linear() {
3
+ [[ -n "${LINEAR_API_KEY:-}" ]] && return 0
4
+ [[ -f "$CW_HOME/tokens.env" ]] && grep -q '^LINEAR_API_KEY=' "$CW_HOME/tokens.env"
5
+ }
6
+
7
+ context_fetch_linear() {
8
+ local url="$1"
9
+ local id; id=$(printf '%s' "$url" | grep -oE '[A-Z]+-[0-9]+' | head -1)
10
+ [[ -n "$id" ]] || return 1
11
+ local key="${LINEAR_API_KEY:-}"
12
+ [[ -z "$key" && -f "$CW_HOME/tokens.env" ]] && \
13
+ key=$(sed -n 's/^LINEAR_API_KEY=//p' "$CW_HOME/tokens.env" | head -1)
14
+ [[ -n "$key" ]] || return 1
15
+ local api="${CW_LINEAR_API:-https://api.linear.app/graphql}"
16
+ CW_LINEAR_KEY="$key" CW_LINEAR_ID="$id" CW_LINEAR_URL="$api" CW_LINEAR_META="${CW_CONTEXT_META:-}" python3 - <<'PY'
17
+ import json, os, sys, urllib.request
18
+ q = """query($id:String!){issue(id:$id){identifier title description branchName priority
19
+ comments{nodes{body}}}}"""
20
+ req = urllib.request.Request(
21
+ os.environ["CW_LINEAR_URL"],
22
+ data=json.dumps({"query": q, "variables": {"id": os.environ["CW_LINEAR_ID"]}}).encode(),
23
+ headers={"Authorization": os.environ["CW_LINEAR_KEY"], "Content-Type": "application/json"},
24
+ )
25
+ try:
26
+ with urllib.request.urlopen(req, timeout=15) as r:
27
+ d = json.load(r)
28
+ except Exception as e:
29
+ print(f"_Linear fetch failed: {e}_", file=sys.stderr)
30
+ sys.exit(1)
31
+ i = (d.get("data") or {}).get("issue")
32
+ if not i:
33
+ sys.exit(1)
34
+ # the branch goes to cw as json too, so it never has to parse the markdown
35
+ if os.environ["CW_LINEAR_META"] and i.get("branchName"):
36
+ with open(os.environ["CW_LINEAR_META"], "w") as f:
37
+ json.dump({"branch": i["branchName"]}, f)
38
+ print(f"**{i['identifier']} — {i['title']}**")
39
+ if i.get("branchName"):
40
+ print(f"Branch: `{i['branchName']}`")
41
+ print()
42
+ print(i.get("description") or "_No description._")
43
+ nodes = (i.get("comments") or {}).get("nodes") or []
44
+ if nodes:
45
+ print("\n**Comments**")
46
+ for c in nodes:
47
+ print(f"- {c['body']}")
48
+ PY
49
+ }