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.
- package/LICENSE +21 -0
- package/console/assets/addon-fit-BlxrFPDK.js +1 -0
- package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/console/assets/index-Digctq9W.css +32 -0
- package/console/assets/index-ItVUTuQf.js +31 -0
- package/console/assets/xterm-DlVFs1Kw.js +9 -0
- package/console/index.html +13 -0
- package/cw/.bundle.json +4 -0
- package/cw/agents/presentation-curator.md +119 -0
- package/cw/agents/weather-agent.md +67 -0
- package/cw/cw +6188 -0
- package/cw/cw-shell-integration.sh +132 -0
- package/cw/hooks/HOOKS-README.md +471 -0
- package/cw/hooks/config/hooks-config.json +24 -0
- package/cw/hooks/scripts/auto-compact.py +133 -0
- package/cw/hooks/scripts/hooks.py +472 -0
- package/cw/hooks/scripts/review-autoclose.py +171 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
- package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
- package/cw/hooks/sounds/configchange/configchange.wav +0 -0
- package/cw/hooks/sounds/notification/notification.mp3 +0 -0
- package/cw/hooks/sounds/notification/notification.wav +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
- package/cw/hooks/sounds/precompact/precompact.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
- package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
- package/cw/hooks/sounds/setup/Setup.wav +0 -0
- package/cw/hooks/sounds/stop/stop.mp3 +0 -0
- package/cw/hooks/sounds/stop/stop.wav +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
- package/cw/install.sh +243 -0
- package/cw/lib/context/github.sh +51 -0
- package/cw/lib/context/linear.sh +49 -0
- package/cw/lib/context/notion.sh +38 -0
- package/cw/lib/dashboard/activity-hook.py +134 -0
- package/cw/lib/dashboard/index.html +1515 -0
- package/cw/lib/dashboard/server.py +379 -0
- package/cw/lib/dashboard/start.sh +15 -0
- package/cw/lib/harnesses/claude.sh +92 -0
- package/cw/lib/harnesses/codex.sh +245 -0
- package/cw/lib/harnesses/opencode.sh +120 -0
- package/cw/lib/harnesses/pi.sh +66 -0
- package/cw/lib/iterm2.sh +140 -0
- package/cw/templates/CLAUDE.template.md +48 -0
- package/dist/index.js +4376 -0
- 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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|