Add codex-task-server-sync skill

这个提交包含在:
Codex
2026-03-16 23:46:45 -07:00
父节点 b2c5ef588d
当前提交 fc8ad7c145
修改 8 个文件,包含 1257 行新增0 行删除

查看文件

@@ -0,0 +1,635 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=""
project_name=""
server_host="root@8.211.173.24"
server_root="/root/continue"
gitea_base_url="https://git.hk.hao.work"
repo_owner="zt"
visibility="private"
branch="main"
sync_interval="15"
continuity_mode="handoff-summary"
overwrite_existing="backup"
gitea_token="${GITEA_TOKEN:-}"
gitea_token_file=""
install_launchd=1
install_systemd=1
enable_services=1
skill_dir="$(cd "$(dirname "$0")/.." && pwd)"
usage() {
cat <<'EOF'
Usage: run.sh --project-dir <path> [options]
Bootstrap continuous sync for a local Codex workspace:
- create or reuse a Gitea repo
- push the current workspace snapshot
- provision /root/continue/<project> on the server
- generate .codex-sync metadata and handoff files
- install local launchd and remote systemd timers
Options:
--project-dir <path> Local workspace to migrate (required)
--project-name <name> Repo and server project name (default: basename of project dir)
--server-host <user@host> Remote SSH target (default: root@8.211.173.24)
--server-root <path> Remote parent directory (default: /root/continue)
--gitea-base-url <url> Gitea base URL (default: https://git.hk.hao.work)
--repo-owner <name> Gitea owner/org (default: zt)
--visibility <value> private|public|internal (default: private)
--branch <name> Git branch to synchronize (default: main)
--sync-interval <seconds> Background sync interval (default: 15)
--gitea-token <token> Gitea API token
--gitea-token-file <path> Read Gitea API token from file
--overwrite-existing <mode> backup|replace|abort (default: backup)
--no-launchd Skip local launchd installation
--no-systemd Skip remote systemd installation
--no-enable-services Install services but do not enable/start them
-h, --help Show this message
EOF
}
die() {
echo "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
sanitize_slug() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g'
}
abs_path() {
python3 - "$1" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).expanduser().resolve())
PY
}
append_unique_line() {
local file_path="$1"
local line="$2"
mkdir -p "$(dirname "$file_path")"
touch "$file_path"
if ! grep -Fxq "$line" "$file_path"; then
printf '%s\n' "$line" >>"$file_path"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
project_dir="$2"
shift 2
;;
--project-name)
project_name="$2"
shift 2
;;
--server-host)
server_host="$2"
shift 2
;;
--server-root)
server_root="$2"
shift 2
;;
--gitea-base-url)
gitea_base_url="$2"
shift 2
;;
--repo-owner)
repo_owner="$2"
shift 2
;;
--visibility)
visibility="$2"
shift 2
;;
--branch)
branch="$2"
shift 2
;;
--sync-interval)
sync_interval="$2"
shift 2
;;
--gitea-token)
gitea_token="$2"
shift 2
;;
--gitea-token-file)
gitea_token_file="$2"
shift 2
;;
--overwrite-existing)
overwrite_existing="$2"
shift 2
;;
--no-launchd)
install_launchd=0
shift
;;
--no-systemd)
install_systemd=0
shift
;;
--no-enable-services)
enable_services=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
[[ -n "$project_dir" ]] || die "--project-dir is required"
case "$visibility" in
private|public|internal) ;;
*) die "--visibility must be private, public, or internal" ;;
esac
if [[ "$visibility" == "internal" ]]; then
echo "Warning: this Gitea instance does not expose an internal repository visibility field in the create/edit API; the repo will be created as private." >&2
fi
case "$overwrite_existing" in
backup|replace|abort) ;;
*) die "--overwrite-existing must be backup, replace, or abort" ;;
esac
[[ "$sync_interval" =~ ^[0-9]+$ ]] || die "--sync-interval must be an integer number of seconds"
if [[ -n "$gitea_token_file" ]]; then
gitea_token_file="$(abs_path "$gitea_token_file")"
[[ -f "$gitea_token_file" ]] || die "Token file not found: $gitea_token_file"
gitea_token="$(<"$gitea_token_file")"
fi
gitea_token="${gitea_token#"${gitea_token%%[![:space:]]*}"}"
gitea_token="${gitea_token%"${gitea_token##*[![:space:]]}"}"
[[ -n "$gitea_token" ]] || die "Provide --gitea-token, --gitea-token-file, or GITEA_TOKEN"
require_cmd bash
require_cmd curl
require_cmd git
require_cmd jq
require_cmd python3
require_cmd rsync
require_cmd ssh
if [[ "$install_launchd" -eq 1 ]]; then
require_cmd launchctl
fi
project_dir="$(abs_path "$project_dir")"
[[ -d "$project_dir" ]] || die "Project directory not found: $project_dir"
if [[ -z "$project_name" ]]; then
project_name="$(basename "$project_dir")"
fi
project_slug="$(sanitize_slug "$project_name")"
[[ -n "$project_slug" ]] || die "Failed to derive a valid slug from project name: $project_name"
remote_dir="${server_root%/}/$project_name"
local_launchd_label="com.codex.sync.${project_slug}"
local_launchd_plist="$HOME/Library/LaunchAgents/${local_launchd_label}.plist"
remote_service_name="codex-sync-${project_slug}.service"
remote_timer_name="codex-sync-${project_slug}.timer"
ssh_base=(
ssh
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
"$server_host"
)
for remote_cmd in git python3 bash; do
"${ssh_base[@]}" "command -v $remote_cmd >/dev/null 2>&1" || die "Remote host is missing required command: $remote_cmd"
done
if [[ "$install_systemd" -eq 1 ]]; then
"${ssh_base[@]}" "command -v systemctl >/dev/null 2>&1" || die "Remote host is missing required command: systemctl"
fi
repo_info_json="$(python3 - "$gitea_base_url" "$gitea_token" "$repo_owner" "$project_name" "$visibility" "$branch" <<'PY'
import json
import sys
import urllib.error
import urllib.request
base_url, token, owner, repo_name, visibility, branch = sys.argv[1:]
headers = {
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def request_json(url, method="GET", payload=None):
req = urllib.request.Request(url, method=method, headers=headers, data=payload)
with urllib.request.urlopen(req, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
user = request_json(f"{base_url}/api/v1/user")
login = user["login"]
repo_endpoint = f"{base_url}/api/v1/repos/{owner}/{repo_name}"
try:
repo = request_json(repo_endpoint)
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
payload = {
"name": repo_name,
"default_branch": branch,
"description": f"Auto-synced Codex workspace for {repo_name}",
"auto_init": False,
"private": visibility != "public",
}
create_url = (
f"{base_url}/api/v1/user/repos"
if owner == login
else f"{base_url}/api/v1/orgs/{owner}/repos"
)
repo = request_json(create_url, method="POST", payload=json.dumps(payload).encode("utf-8"))
print(json.dumps({"login": login, "repo": repo}))
PY
)"
repo_login="$(printf '%s' "$repo_info_json" | jq -r '.login')"
repo_ssh_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.ssh_url')"
repo_http_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.html_url')"
repo_clone_url="$(printf '%s' "$repo_info_json" | jq -r '.repo.clone_url')"
transport_url="$repo_ssh_url"
transport_mode="ssh"
check_remote_access() {
local url="$1"
local mode="$2"
if [[ "$mode" == "local" ]]; then
git ls-remote "$url" >/dev/null 2>&1
else
"${ssh_base[@]}" "git ls-remote '$url' >/dev/null 2>&1"
fi
}
if ! check_remote_access "$repo_ssh_url" "local" || ! check_remote_access "$repo_ssh_url" "remote"; then
transport_url="$(python3 - "$repo_clone_url" "$repo_login" "$gitea_token" <<'PY'
from urllib.parse import urlsplit, urlunsplit, quote
import sys
clone_url, username, token = sys.argv[1:]
parts = urlsplit(clone_url)
netloc = f"{quote(username, safe='')}:{quote(token, safe='')}@{parts.hostname}"
if parts.port:
netloc += f":{parts.port}"
print(urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)))
PY
)"
transport_mode="https-token"
if ! check_remote_access "$transport_url" "local"; then
die "Neither SSH nor HTTPS token transport can access the repo from the local host"
fi
fi
if [[ ! -d "$project_dir/.git" ]]; then
git -C "$project_dir" init -b "$branch" >/dev/null
fi
git -C "$project_dir" checkout -B "$branch" >/dev/null
if [[ -z "$(git -C "$project_dir" config user.name || true)" ]]; then
git -C "$project_dir" config user.name "Codex Sync"
fi
if [[ -z "$(git -C "$project_dir" config user.email || true)" ]]; then
git -C "$project_dir" config user.email "codex-sync@local"
fi
mkdir -p "$project_dir/.codex-sync/bin"
mkdir -p "$project_dir/.codex-sync/runtime"
cp "$skill_dir/scripts/sync_once.sh" "$project_dir/.codex-sync/bin/sync-once.sh"
cp "$skill_dir/scripts/continue_task.sh" "$project_dir/.codex-sync/bin/continue-task.sh"
chmod +x "$project_dir/.codex-sync/bin/sync-once.sh" "$project_dir/.codex-sync/bin/continue-task.sh"
append_unique_line "$project_dir/.gitignore" ".codex-sync/runtime/"
python3 - "$project_dir/.codex-sync/manifest.json" "$project_name" "$project_slug" "$branch" "$repo_owner" "$repo_http_url" "$repo_ssh_url" "$transport_mode" "$server_host" "$remote_dir" "$sync_interval" "$continuity_mode" "$local_launchd_label" "$local_launchd_plist" "$remote_service_name" "$remote_timer_name" <<'PY'
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
(
manifest_path,
project_name,
project_slug,
branch,
repo_owner,
repo_http_url,
repo_ssh_url,
transport_mode,
server_host,
remote_dir,
sync_interval,
continuity_mode,
local_launchd_label,
local_launchd_plist,
remote_service_name,
remote_timer_name,
) = sys.argv[1:]
manifest = {
"version": 1,
"generatedAt": datetime.now(timezone.utc).isoformat(),
"projectName": project_name,
"projectSlug": project_slug,
"branch": branch,
"repoOwner": repo_owner,
"repoHttpUrl": repo_http_url,
"repoSshUrl": repo_ssh_url,
"remoteTransport": transport_mode,
"serverHost": server_host,
"serverPath": remote_dir,
"syncIntervalSeconds": int(sync_interval),
"continuityMode": continuity_mode,
"conflictPolicy": "stop-and-surface-status",
"gitIdentity": {
"name": "Codex Sync",
"email": "codex-sync@local",
},
"services": {
"launchdLabel": local_launchd_label,
"launchdPlist": local_launchd_plist,
"systemdService": remote_service_name,
"systemdTimer": remote_timer_name,
},
}
path = Path(manifest_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
PY
python3 "$skill_dir/scripts/generate_handoff.py" \
--project-dir "$project_dir" \
--output "$project_dir/.codex-sync/handoff.md"
cat >"$project_dir/.codex-sync/README.md" <<EOF
# Codex Sync
This workspace is synchronized through Gitea and mirrored to \`${remote_dir}\` on \`${server_host}\`.
## Inspect Status
\`\`\`bash
cat .codex-sync/manifest.json
cat .codex-sync/runtime/status.json
cat .codex-sync/runtime/last-error.log
git status -sb
\`\`\`
## Local launchd
- Label: \`${local_launchd_label}\`
- Plist: \`${local_launchd_plist}\`
\`\`\`bash
launchctl print "gui/\$(id -u)/${local_launchd_label}"
launchctl kickstart -k "gui/\$(id -u)/${local_launchd_label}"
\`\`\`
## Remote systemd
- Service: \`${remote_service_name}\`
- Timer: \`${remote_timer_name}\`
\`\`\`bash
ssh ${server_host} 'systemctl status ${remote_service_name} ${remote_timer_name}'
ssh ${server_host} 'systemctl restart ${remote_service_name}'
ssh ${server_host} 'systemctl restart ${remote_timer_name}'
\`\`\`
## Continue On The Server
\`\`\`bash
ssh ${server_host} 'cd ${remote_dir} && ./.codex-sync/bin/continue-task.sh'
\`\`\`
If sync stops after a conflict, resolve the git state, remove \`.codex-sync/runtime/blocked\`, and run \`./.codex-sync/bin/sync-once.sh\` again.
EOF
git -C "$project_dir" add .gitignore .codex-sync
if ! git -C "$project_dir" diff --cached --quiet || [[ -z "$(git -C "$project_dir" rev-parse --verify HEAD 2>/dev/null || true)" ]]; then
git -C "$project_dir" add -A
git -C "$project_dir" commit -m "Bootstrap Codex task sync" >/dev/null
fi
if git -C "$project_dir" remote get-url origin >/dev/null 2>&1; then
git -C "$project_dir" remote set-url origin "$transport_url"
else
git -C "$project_dir" remote add origin "$transport_url"
fi
git -C "$project_dir" push -u origin "$branch" >/dev/null 2>&1 || "$project_dir/.codex-sync/bin/sync-once.sh" --project-dir "$project_dir" --host-label "bootstrap-local"
"${ssh_base[@]}" bash -s -- "$remote_dir" "$branch" "$transport_url" "$repo_ssh_url" "$repo_clone_url" "$overwrite_existing" <<'EOF_REMOTE_REPO'
set -euo pipefail
remote_dir="$1"
branch="$2"
transport_url="$3"
repo_ssh_url="$4"
repo_clone_url="$5"
overwrite_existing="$6"
normalize_remote() {
python3 - "$1" <<'PY'
import re
import sys
from urllib.parse import urlsplit, urlunsplit
raw = sys.argv[1]
if raw.startswith("ssh://"):
parts = urlsplit(raw)
print(f"{parts.hostname}:{parts.path.lstrip('/')}")
elif re.match(r"^[^@]+@[^:]+:.+$", raw):
print(raw.split("@", 1)[1])
else:
parts = urlsplit(raw)
host = parts.hostname or ""
print(f"{host}:{parts.path.lstrip('/')}")
PY
}
expected_ssh="$(normalize_remote "$repo_ssh_url")"
expected_http="$(normalize_remote "$repo_clone_url")"
if [[ -d "$remote_dir" ]]; then
current_remote=""
if [[ -d "$remote_dir/.git" ]]; then
current_remote="$(git -C "$remote_dir" config --get remote.origin.url || true)"
fi
if [[ -n "$current_remote" ]]; then
current_norm="$(normalize_remote "$current_remote")"
else
current_norm=""
fi
if [[ "$current_norm" != "$expected_ssh" && "$current_norm" != "$expected_http" ]]; then
case "$overwrite_existing" in
backup)
mv "$remote_dir" "${remote_dir}.bak.$(date +%Y%m%d%H%M%S)"
;;
replace)
rm -rf "$remote_dir"
;;
abort)
echo "Remote path exists and is unrelated: $remote_dir" >&2
exit 1
;;
esac
fi
fi
mkdir -p "$(dirname "$remote_dir")"
if [[ ! -d "$remote_dir/.git" ]]; then
git clone "$transport_url" "$remote_dir" >/dev/null 2>&1
fi
if [[ -n "$(git -C "$remote_dir" status --porcelain || true)" ]]; then
echo "Remote checkout is dirty and cannot be updated safely: $remote_dir" >&2
exit 1
fi
git -C "$remote_dir" fetch origin "$branch" >/dev/null 2>&1 || true
if git -C "$remote_dir" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
git -C "$remote_dir" checkout -B "$branch" "origin/$branch" >/dev/null 2>&1
else
git -C "$remote_dir" checkout -B "$branch" >/dev/null 2>&1
fi
if [[ -z "$(git -C "$remote_dir" config user.name || true)" ]]; then
git -C "$remote_dir" config user.name "Codex Sync"
fi
if [[ -z "$(git -C "$remote_dir" config user.email || true)" ]]; then
git -C "$remote_dir" config user.email "codex-sync@remote"
fi
EOF_REMOTE_REPO
if [[ "$install_launchd" -eq 1 ]]; then
mkdir -p "$(dirname "$local_launchd_plist")"
cat >"$local_launchd_plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${local_launchd_label}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${project_dir}/.codex-sync/bin/sync-once.sh</string>
<string>--project-dir</string>
<string>${project_dir}</string>
<string>--host-label</string>
<string>local-$(hostname -s)</string>
</array>
<key>WorkingDirectory</key>
<string>${project_dir}</string>
<key>StartInterval</key>
<integer>${sync_interval}</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>${project_dir}/.codex-sync/runtime/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>${project_dir}/.codex-sync/runtime/launchd.err.log</string>
</dict>
</plist>
EOF
if [[ "$enable_services" -eq 1 ]]; then
launchctl bootout "gui/$(id -u)" "$local_launchd_plist" >/dev/null 2>&1 || true
if ! launchctl bootstrap "gui/$(id -u)" "$local_launchd_plist" >/dev/null 2>&1; then
launchctl load -w "$local_launchd_plist" >/dev/null 2>&1
fi
launchctl kickstart -k "gui/$(id -u)/${local_launchd_label}" >/dev/null 2>&1 || true
fi
fi
if [[ "$install_systemd" -eq 1 ]]; then
"${ssh_base[@]}" bash -s -- "$remote_service_name" "$remote_timer_name" "$remote_dir" "$sync_interval" "$enable_services" <<'EOF_REMOTE_SYSTEMD'
set -euo pipefail
service_name="$1"
timer_name="$2"
remote_dir="$3"
sync_interval="$4"
enable_services="$5"
service_path="/etc/systemd/system/$service_name"
timer_path="/etc/systemd/system/$timer_name"
cat >"$service_path" <<EOF_SERVICE
[Unit]
Description=Codex task sync for $(basename "$remote_dir")
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$remote_dir
ExecStart=/usr/bin/bash -lc 'cd "$remote_dir" && ./.codex-sync/bin/sync-once.sh --project-dir "$remote_dir" --host-label "remote-$(hostname -s)"'
EOF_SERVICE
cat >"$timer_path" <<EOF_TIMER
[Unit]
Description=Run $service_name every ${sync_interval}s
[Timer]
OnBootSec=15sec
OnUnitActiveSec=${sync_interval}s
Persistent=true
Unit=$service_name
[Install]
WantedBy=timers.target
EOF_TIMER
systemctl daemon-reload
if [[ "$enable_services" -eq 1 ]]; then
systemctl enable --now "$timer_name" >/dev/null 2>&1
systemctl start "$service_name" >/dev/null 2>&1 || true
fi
EOF_REMOTE_SYSTEMD
fi
"$project_dir/.codex-sync/bin/sync-once.sh" --project-dir "$project_dir" --host-label "bootstrap-local"
"${ssh_base[@]}" "cd '$remote_dir' && ./.codex-sync/bin/sync-once.sh --project-dir '$remote_dir' --host-label 'bootstrap-remote'" || true
cat <<EOF
Bootstrap complete.
Project: $project_name
Project root: $project_dir
Repo owner: $repo_owner
Repo URL: $repo_http_url
Transport: $transport_mode
Remote path: $remote_dir
Branch: $branch
Launchd: $local_launchd_label
Systemd: $remote_service_name / $remote_timer_name
EOF