Skip to content

Commit 38c429f

Browse files
committed
chore(ci): improve codex support
Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 7ca3fa6 commit 38c429f

30 files changed

Lines changed: 496 additions & 415 deletions

.agents/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22

33
https://agentskills.io/
44

5-
Common skills share for various platforms. Symlink used to link skills for `.claude/skills`. The symlinks are source controlled by default.
5+
Common skills and hooks shared across agent platforms. Symlinks are source controlled by default.
66

77
```shell
88
# symlink skills
99
ln -sfn ../.agents/skills .claude/skills
1010

11+
# hooks
12+
# .claude/settings.json references .agents/hooks directly
13+
# .codex/config.toml enables hooks and .codex/hooks.json references .agents/hooks
14+
# Hooks resolve the project root from AGENTS_PROJECT_DIR, agent-specific env vars,
15+
# hook JSON cwd fields, the hook script location, or git.
16+
1117
# symlink context
1218
ln -s AGENTS.md CLAUDE.md
1319
```

.agents/hooks/lib/project-root.sh

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env bash
2+
3+
normalize_dir() {
4+
local dir="$1"
5+
6+
if [[ -z "$dir" || ! -d "$dir" ]]; then
7+
return 1
8+
fi
9+
10+
(cd "$dir" 2>/dev/null && pwd -P)
11+
}
12+
13+
looks_like_project_root() {
14+
local dir="$1"
15+
16+
[[ -d "$dir/.git" || -f "$dir/.git" || -f "$dir/AGENTS.md" || -f "$dir/pnpm-workspace.yaml" || -d "$dir/.agents" ]]
17+
}
18+
19+
candidate_from_json() {
20+
local input="$1"
21+
22+
if [[ -z "$input" ]] || ! command -v jq >/dev/null 2>&1; then
23+
return 1
24+
fi
25+
26+
jq -r '.cwd // .workspace_root // .workspaceRoot // .project_dir // .projectDir // empty' <<<"$input" 2>/dev/null
27+
}
28+
29+
resolve_project_root() {
30+
local input="${1:-}"
31+
local hooks_dir="${2:-}"
32+
local candidate normalized json_candidate
33+
34+
json_candidate=$(candidate_from_json "$input" || true)
35+
36+
for candidate in \
37+
"${AGENTS_PROJECT_DIR:-}" \
38+
"${CLAUDE_PROJECT_DIR:-}" \
39+
"${CODEX_PROJECT_DIR:-}" \
40+
"${CODEX_WORKSPACE_ROOT:-}" \
41+
"${WORKSPACE_ROOT:-}" \
42+
"${PROJECT_DIR:-}" \
43+
"$json_candidate" \
44+
"${PWD:-}"; do
45+
normalized=$(normalize_dir "$candidate") || continue
46+
if looks_like_project_root "$normalized"; then
47+
printf '%s\n' "$normalized"
48+
return 0
49+
fi
50+
done
51+
52+
if [[ -n "$hooks_dir" ]]; then
53+
normalized=$(normalize_dir "$hooks_dir/../..") || true
54+
if [[ -n "${normalized:-}" ]] && looks_like_project_root "$normalized"; then
55+
printf '%s\n' "$normalized"
56+
return 0
57+
fi
58+
fi
59+
60+
candidate=$(git rev-parse --show-toplevel 2>/dev/null || true)
61+
normalized=$(normalize_dir "$candidate") || true
62+
if [[ -n "${normalized:-}" ]]; then
63+
printf '%s\n' "$normalized"
64+
return 0
65+
fi
66+
67+
return 1
68+
}
69+
70+
resolve_hook_path() {
71+
local project_root="$1"
72+
local path="$2"
73+
74+
if [[ -z "$path" ]]; then
75+
return 1
76+
fi
77+
78+
case "$path" in
79+
/*) printf '%s\n' "$path" ;;
80+
*) printf '%s/%s\n' "$project_root" "$path" ;;
81+
esac
82+
}
83+
84+
hook_relative_path() {
85+
local project_root="$1"
86+
local path="$2"
87+
88+
case "$path" in
89+
"$project_root"/*) printf '%s\n' "${path#"$project_root"/}" ;;
90+
*) printf '%s\n' "$path" ;;
91+
esac
92+
}
93+
94+
hook_command_from_input() {
95+
local input="$1"
96+
97+
if [[ -z "$input" ]] || ! command -v jq >/dev/null 2>&1; then
98+
return 1
99+
fi
100+
101+
jq -r '.tool_input.command // .command // empty' <<<"$input" 2>/dev/null
102+
}
103+
104+
hook_file_paths_from_input() {
105+
local input="$1"
106+
local command
107+
108+
if [[ -n "$input" ]] && command -v jq >/dev/null 2>&1; then
109+
jq -r '
110+
[
111+
.tool_input.file_path?,
112+
.tool_input.filePath?,
113+
.tool_input.path?,
114+
.file_path?,
115+
.filePath?,
116+
.path?
117+
]
118+
| .[]
119+
| select(type == "string" and length > 0)
120+
' <<<"$input" 2>/dev/null || true
121+
fi
122+
123+
command=$(hook_command_from_input "$input" || true)
124+
if [[ -n "$command" ]]; then
125+
awk '
126+
/^\*\*\* (Add|Update|Delete) File: / {
127+
sub(/^\*\*\* (Add|Update|Delete) File: /, "")
128+
print
129+
}
130+
/^\*\*\* Move to: / {
131+
sub(/^\*\*\* Move to: /, "")
132+
print
133+
}
134+
' <<<"$command"
135+
fi
136+
}

.agents/hooks/notification.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ "$(uname -s)" == "Darwin" ]] && command -v osascript >/dev/null 2>&1; then
5+
osascript -e 'display notification "Agent needs your attention" with title "Agent"'
6+
fi
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
INPUT=$(cat)
5+
HOOK_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
6+
source "$HOOK_DIR/lib/project-root.sh"
7+
PROJECT_ROOT=$(resolve_project_root "$INPUT" "$HOOK_DIR") || exit 0
8+
FILE_PATHS=$(hook_file_paths_from_input "$INPUT")
9+
10+
if [[ -z "$FILE_PATHS" ]]; then
11+
exit 0
12+
fi
13+
14+
FAILED=0
15+
16+
mark_failed() {
17+
FAILED=1
18+
}
19+
20+
run_prettier() {
21+
local output exit_code
22+
23+
output=$(cd "$PROJECT_ROOT" && pnpm exec prettier --write --ignore-unknown --no-error-on-unmatched-pattern "$FILE_PATH" 2>&1) || exit_code=$?
24+
25+
if [[ ${exit_code:-0} -ne 0 && -n "$output" ]]; then
26+
echo "$output" >&2
27+
mark_failed
28+
fi
29+
}
30+
31+
run_eslint() {
32+
case "$FILE_PATH" in
33+
*.ts|*.js|*.css) ;;
34+
*) return 0 ;;
35+
esac
36+
37+
case "$FILE_PATH" in
38+
*/dist/*|*/node_modules/*|*/__screenshots__/*|*/generated/*) return 0 ;;
39+
esac
40+
41+
local dir project_dir rel_path json_output hard_errors total_errors readable
42+
43+
dir=$(dirname "$FILE_PATH")
44+
project_dir=""
45+
while [[ "$dir" != "/" && "$dir" != "." ]]; do
46+
if [[ -f "$dir/eslint.config.js" ]]; then
47+
project_dir="$dir"
48+
break
49+
fi
50+
dir=$(dirname "$dir")
51+
done
52+
53+
if [[ -z "$project_dir" ]]; then
54+
return 0
55+
fi
56+
57+
rel_path=$(hook_relative_path "$project_dir" "$FILE_PATH")
58+
59+
local soft_rules="no-unused-vars|@typescript-eslint/no-unused-vars"
60+
61+
json_output=$(cd "$project_dir" && pnpm exec eslint -c ./eslint.config.js --no-warn-ignored --cache --cache-location .eslintcache/ --format json "$rel_path" 2>/dev/null) || true
62+
63+
hard_errors=$(echo "$json_output" | jq -r --arg soft "$soft_rules" '
64+
[.[].messages[] | select(.severity == 2) | select(.ruleId | test($soft) | not)] | length
65+
') 2>/dev/null || hard_errors="0"
66+
67+
total_errors=$(echo "$json_output" | jq -r '
68+
[.[].messages[] | select(.severity == 2)] | length
69+
') 2>/dev/null || total_errors="0"
70+
71+
if [[ "$total_errors" == "0" ]]; then
72+
return 0
73+
fi
74+
75+
readable=$(cd "$project_dir" && pnpm exec eslint -c ./eslint.config.js --no-warn-ignored --color --cache --cache-location .eslintcache/ "$rel_path" 2>&1) || true
76+
77+
if [[ "$hard_errors" != "0" ]]; then
78+
echo "$readable" >&2
79+
mark_failed
80+
else
81+
echo "$readable" >&2
82+
fi
83+
}
84+
85+
run_vale() {
86+
case "$FILE_PATH" in
87+
*.md|*.ts) ;;
88+
*) return 0 ;;
89+
esac
90+
91+
case "$FILE_PATH" in
92+
*.test.*|*/starters/*|*/404/*|*/vendor/*|*/changelog/*|*/icons/*|*/generated/*|*/dist/*|*/LICENSE*|*/CHANGELOG*|*/NOTICE*) return 0 ;;
93+
esac
94+
95+
case "$FILE_PATH" in
96+
*/.claude/plans/*|*/.claude/projects/*) return 0 ;;
97+
esac
98+
99+
local output exit_code
100+
101+
output=$(cd "$PROJECT_ROOT" && config/vale/bin/vale --config .vale.ini "$FILE_PATH" 2>&1) || exit_code=$?
102+
103+
if [[ ${exit_code:-0} -ne 0 && -n "$output" ]]; then
104+
echo "$output" >&2
105+
mark_failed
106+
fi
107+
}
108+
109+
run_stylelint() {
110+
case "$FILE_PATH" in
111+
*.css) ;;
112+
*) return 0 ;;
113+
esac
114+
115+
case "$FILE_PATH" in
116+
*/dist/*|*/node_modules/*|*/vendor/*) return 0 ;;
117+
esac
118+
119+
local repo_root dir project_dir rel_path output exit_code
120+
121+
repo_root="$PROJECT_ROOT"
122+
dir=$(dirname "$FILE_PATH")
123+
project_dir=""
124+
while [[ "$dir" != "/" && "$dir" != "." ]]; do
125+
if [[ -f "$dir/package.json" ]] && jq -e '.wireit["lint:style"]' "$dir/package.json" >/dev/null 2>&1; then
126+
project_dir="$dir"
127+
break
128+
fi
129+
dir=$(dirname "$dir")
130+
done
131+
132+
if [[ -z "$project_dir" ]]; then
133+
return 0
134+
fi
135+
136+
rel_path=$(hook_relative_path "$project_dir" "$FILE_PATH")
137+
138+
output=$(cd "$project_dir" && pnpm exec stylelint --config="$repo_root/stylelint.config.mjs" --color "$rel_path" 2>&1) || exit_code=$?
139+
140+
if [[ ${exit_code:-0} -ne 0 && -n "$output" ]]; then
141+
echo "$output" >&2
142+
mark_failed
143+
fi
144+
}
145+
146+
while IFS= read -r FILE_PATH; do
147+
if [[ -z "$FILE_PATH" ]]; then
148+
continue
149+
fi
150+
151+
FILE_PATH=$(resolve_hook_path "$PROJECT_ROOT" "$FILE_PATH")
152+
153+
if [[ ! -e "$FILE_PATH" || -d "$FILE_PATH" ]]; then
154+
continue
155+
fi
156+
157+
run_prettier
158+
run_eslint
159+
run_vale
160+
run_stylelint
161+
done <<<"$FILE_PATHS"
162+
163+
if [[ "$FAILED" -ne 0 ]]; then
164+
exit 2
165+
fi
166+
167+
exit 0
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
set -euo pipefail
33

44
INPUT=$(cat)
5-
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
5+
HOOK_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
6+
source "$HOOK_DIR/lib/project-root.sh"
7+
COMMAND=$(hook_command_from_input "$INPUT" || true)
68

79
# Exit early if not a git command
810
if [[ -z "$COMMAND" ]] || ! echo "$COMMAND" | grep -qE '^\s*git\s'; then

0 commit comments

Comments
 (0)