@mgeri1993/claude-task-manager 1.0.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/api/index.php ADDED
@@ -0,0 +1,173 @@
1
+ <?php
2
+ /**
3
+ * api/index.php — the task-manager board's WRITE ENDPOINT (multi-project version).
4
+ *
5
+ * The PHP built-in server runs this file directly. Started via docker compose (see
6
+ * docker-compose.yml), or manually (docroot = the project root):
7
+ * php -S localhost:3333 -t /Users/mgeri1993/code/projects/claude-task-manager
8
+ * → board: http://localhost:3333/
9
+ * → endpoint: http://localhost:3333/api/index.php
10
+ *
11
+ * Writes go EXCLUSIVELY through engine/task.sh (allowlisted commands, `--as <as>`,
12
+ * `TM_DIR=<project data dir>`), so tasks.json's ONLY writer remains task.sh (atomic lock,
13
+ * history, events.jsonl, per-agent inbox). The browser NEVER writes the JSON directly.
14
+ *
15
+ * The `project` field is allowlisted against data/projects.json's registered ids — this
16
+ * decides which project's data directory (TM_DIR) the engine gets.
17
+ *
18
+ * The optional `lang` field (the board's current UI language) is persisted as the
19
+ * project's preferred language in a small `.board-lang` file next to tasks.json — NOT
20
+ * inside any task. engine/task.sh reads it on every invocation and prints a reminder, so
21
+ * an agent running task.sh next knows which language to reply/work in.
22
+ *
23
+ * Security:
24
+ * - proc_open ARRAY form → no shell, no injection (no escaping needed on POSIX).
25
+ * - command allowlist: destructive commands (rm/restore/raw/archive) are NOT exposed.
26
+ * - the real perimeter is docker-compose.yml's port binding (127.0.0.1:<port>:<port>) —
27
+ * the container is unreachable from anywhere but the host's own loopback. Requests that
28
+ * DO reach this script still show a private/link-local REMOTE_ADDR (the docker bridge's
29
+ * gateway, e.g. 172.x.x.1), never a public one, since Docker NATs the host-loopback
30
+ * connection — so this check accepts private ranges too, not just literal 127.0.0.1.
31
+ * - the project id is checked against data/projects.json's registered list — the client
32
+ * can never supply an arbitrary TM_DIR.
33
+ */
34
+
35
+ header('Content-Type: application/json; charset=utf-8');
36
+
37
+ /** True for 127.0.0.0/8, ::1, and the RFC1918 private ranges Docker's bridge networks use. */
38
+ function is_local_or_private_addr(string $addr): bool
39
+ {
40
+ if ($addr === '' || $addr === '::1') return true;
41
+ if (str_starts_with($addr, '127.')) return true;
42
+ $long = ip2long($addr);
43
+ if ($long === false) return false;
44
+ $inRange = static fn(string $cidr, int $bits) => ($long & ~((1 << (32 - $bits)) - 1)) === (ip2long($cidr) & ~((1 << (32 - $bits)) - 1));
45
+ return $inRange('10.0.0.0', 8) || $inRange('172.16.0.0', 12) || $inRange('192.168.0.0', 16);
46
+ }
47
+
48
+ $remote = $_SERVER['REMOTE_ADDR'] ?? '';
49
+ if (!is_local_or_private_addr($remote)) {
50
+ http_response_code(403);
51
+ echo json_encode(['ok' => false, 'error' => 'localhost only']);
52
+ exit;
53
+ }
54
+
55
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
56
+ http_response_code(405);
57
+ echo json_encode(['ok' => false, 'error' => 'POST only']);
58
+ exit;
59
+ }
60
+
61
+ // task.sh commands exposed from the board. rm/restore/raw/archive are DELIBERATELY excluded.
62
+ const ALLOWED = [
63
+ 'status', 'note', 'priority', 'module', 'tag', 'assign', 'dep',
64
+ 'status-many', 'reopen', 'add',
65
+ ];
66
+
67
+ const ALLOWED_LANGS = ['en', 'hu'];
68
+
69
+ $ROOT_DIR = dirname(__DIR__); // claude-task-manager/
70
+ $ENGINE_TASK_SH = $ROOT_DIR . '/engine/task.sh';
71
+ $PROJECTS_FILE = $ROOT_DIR . '/data/projects.json';
72
+
73
+ $raw = file_get_contents('php://input') ?: '';
74
+ $body = json_decode($raw, true);
75
+ if (!is_array($body)) {
76
+ http_response_code(400);
77
+ echo json_encode(['ok' => false, 'error' => 'invalid JSON body']);
78
+ exit;
79
+ }
80
+
81
+ // Actor (--as): the board SENDS who the caller is — a selectable agent/reviewer, NOT a
82
+ // fixed "human".
83
+ $actor = isset($body['as']) ? preg_replace('/[^A-Za-z0-9_.-]/', '', (string) $body['as']) : '';
84
+ if ($actor === '') {
85
+ http_response_code(400);
86
+ echo json_encode(['ok' => false, 'error' => 'missing "as" (agent name) — set it on the board: "As …"']);
87
+ exit;
88
+ }
89
+
90
+ // Project: allowlisted against data/projects.json's registered ids — the client can never
91
+ // supply an arbitrary path, only an already-registered project id.
92
+ $projectId = isset($body['project']) ? preg_replace('/[^A-Za-z0-9_-]/', '', (string) $body['project']) : '';
93
+ if ($projectId === '') {
94
+ http_response_code(400);
95
+ echo json_encode(['ok' => false, 'error' => 'missing "project" (set it in the Source selector)']);
96
+ exit;
97
+ }
98
+
99
+ $projects = [];
100
+ if (is_file($PROJECTS_FILE)) {
101
+ $decoded = json_decode((string) file_get_contents($PROJECTS_FILE), true);
102
+ if (is_array($decoded)) $projects = $decoded;
103
+ }
104
+ // Only used to check that $projectId is actually registered — NOT for its stored
105
+ // "dataDir" value, which is a HOST-absolute path baked in by engine/projects.sh (run on
106
+ // the host). Inside the docker container the filesystem root is /app, not the host's path,
107
+ // so the real data dir is always computed relative to THIS script's own $ROOT_DIR instead
108
+ // — that resolves correctly whether this file is running on the host or in the container.
109
+ $known = false;
110
+ foreach ($projects as $p) {
111
+ if (is_array($p) && ($p['id'] ?? null) === $projectId) { $known = true; break; }
112
+ }
113
+ if (!$known) {
114
+ http_response_code(400);
115
+ echo json_encode(['ok' => false, 'error' => "unknown project: $projectId"]);
116
+ exit;
117
+ }
118
+ $dataDir = $ROOT_DIR . '/data/' . $projectId;
119
+
120
+ // Persist the board's current UI language as this project's preferred language (best
121
+ // effort — never fails the request). engine/task.sh reads this file on every run.
122
+ $lang = isset($body['lang']) ? (string) $body['lang'] : '';
123
+ if (in_array($lang, ALLOWED_LANGS, true)) {
124
+ @file_put_contents($dataDir . '/.board-lang', $lang);
125
+ }
126
+
127
+ // One or more operations: { cmd, args } OR { ops: [ {cmd,args}, ... ] }.
128
+ $ops = [];
129
+ if (isset($body['ops']) && is_array($body['ops'])) {
130
+ $ops = $body['ops'];
131
+ } elseif (isset($body['cmd'])) {
132
+ $ops = [['cmd' => $body['cmd'], 'args' => $body['args'] ?? []]];
133
+ }
134
+ if (!$ops) {
135
+ http_response_code(400);
136
+ echo json_encode(['ok' => false, 'error' => 'missing cmd/ops']);
137
+ exit;
138
+ }
139
+
140
+ /** Run one task.sh command as an argv array (no shell), with the project's TM_DIR. */
141
+ function run_task_sh(string $taskSh, string $cmd, array $args, string $actor, string $tmDir): array
142
+ {
143
+ $argv = array_merge([$taskSh, $cmd], array_map('strval', array_values($args)), ['--as', $actor]);
144
+ $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
145
+ $env = ['TM_DIR' => $tmDir, 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin'];
146
+ $proc = proc_open($argv, $desc, $pipes, dirname($taskSh), $env);
147
+ if (!is_resource($proc)) {
148
+ return ['ok' => false, 'code' => -1, 'out' => '', 'err' => 'proc_open failed'];
149
+ }
150
+ $out = stream_get_contents($pipes[1]); fclose($pipes[1]);
151
+ $err = stream_get_contents($pipes[2]); fclose($pipes[2]);
152
+ $code = proc_close($proc);
153
+ return ['ok' => $code === 0, 'code' => $code, 'out' => trim((string) $out), 'err' => trim((string) $err)];
154
+ }
155
+
156
+ $results = [];
157
+ $allOk = true;
158
+ foreach ($ops as $op) {
159
+ $cmd = is_array($op) ? ($op['cmd'] ?? '') : '';
160
+ $args = (is_array($op) && isset($op['args']) && is_array($op['args'])) ? $op['args'] : [];
161
+ if (!in_array($cmd, ALLOWED, true)) {
162
+ $results[] = ['ok' => false, 'cmd' => $cmd, 'err' => "command not allowed: $cmd"];
163
+ $allOk = false;
164
+ break; // don't continue the chain after an invalid step
165
+ }
166
+ $r = run_task_sh($ENGINE_TASK_SH, $cmd, $args, $actor, $dataDir);
167
+ $r['cmd'] = $cmd;
168
+ $results[] = $r;
169
+ if (!$r['ok']) { $allOk = false; break; } // stop at the first failure (don't let note+status split)
170
+ }
171
+
172
+ http_response_code($allOk ? 200 : 422);
173
+ echo json_encode(['ok' => $allOk, 'results' => $results]);
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # add-agent.sh — create a custom teammate agent definition in an ALREADY installed
4
+ # (ctm init-ed) project.
5
+ #
6
+ # Naming convention: claude-task-manager's base set, installed by install.sh, is always
7
+ # "ctm-*" (ctm-frontend-developer / ctm-backend-developer / ctm-code-investigator) —
8
+ # generated/refreshed by "ctm init". Custom agents created with THIS script, as needed by
9
+ # the user, are always named "tm-*" — so at a glance you can tell what's the auto-refreshed
10
+ # base set apart from a custom, hand-edited addition ("ctm init" never touches these).
11
+ #
12
+ # Usage (typically invoked via the "ctm agent add" subcommand):
13
+ # /Users/mgeri1993/code/projects/claude-task-manager/bin/add-agent.sh [target-dir] <name> [description]
14
+ #
15
+ # target-dir – defaults to the current directory's git root (or the cwd)
16
+ # name – the custom agent's name, WITH or WITHOUT the "tm-" prefix — the result is
17
+ # always "tm-<name>" (the "ctm-" prefix is reserved, you cannot use it).
18
+ # description – a short, one-sentence role description (goes into the generated .md's
19
+ # description field)
20
+
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
25
+
26
+ die() { echo "error: $*" >&2; exit 1; }
27
+
28
+ # shellcheck source=engine/check-update.sh
29
+ source "$ROOT_DIR/engine/check-update.sh"
30
+ check_for_updates "$ROOT_DIR"
31
+
32
+ TARGET_ARG="${1:-}"
33
+ if [[ -n "$TARGET_ARG" && -d "$TARGET_ARG" ]]; then
34
+ TARGET_DIR="$(cd "$TARGET_ARG" && pwd)"
35
+ shift
36
+ elif ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
37
+ TARGET_DIR="$ROOT"
38
+ else
39
+ TARGET_DIR="$(pwd)"
40
+ fi
41
+
42
+ RAW_NAME="${1:-}"
43
+ [[ -n "$RAW_NAME" ]] || die "usage: add-agent.sh [target-dir] <name> [description]"
44
+ shift || true
45
+ DESCRIPTION="${*:-Custom, project-specific teammate.}"
46
+
47
+ SKILL_DIR="$TARGET_DIR/.claude/skills/task-manager"
48
+ TASK_SH="$SKILL_DIR/task.sh"
49
+ [[ -x "$TASK_SH" ]] || die "this project is not installed — run first: ctm init (here: $TARGET_DIR)"
50
+
51
+ # Normalize the name: always with a "tm-" prefix; "ctm-" is reserved (the base set's prefix).
52
+ SHORT="${RAW_NAME#tm-}"
53
+ [[ "$SHORT" == ctm-* || "$SHORT" == ctm ]] && die 'the "ctm-" prefix is reserved (for the base agents) — choose a different name'
54
+ [[ "$SHORT" =~ ^[A-Za-z0-9_-]+$ ]] || die "invalid name (only A-Za-z0-9_- allowed): $RAW_NAME"
55
+ AGENT_NAME="tm-$SHORT"
56
+
57
+ # The project label, from the already-installed SKILL.md's "# Task Manager (Label)" heading
58
+ # (more accurate than the folder name, if "ctm init" was given a custom label); falls back
59
+ # to the folder name if there's no such line.
60
+ LABEL="$(sed -n 's/^# Task Manager (\(.*\))$/\1/p' "$SKILL_DIR/SKILL.md" 2>/dev/null | head -1)"
61
+ LABEL="${LABEL:-$(basename "$TARGET_DIR")}"
62
+
63
+ AGENTS_DIR="$TARGET_DIR/.claude/agents"
64
+ mkdir -p "$AGENTS_DIR"
65
+ OUT="$AGENTS_DIR/$AGENT_NAME.md"
66
+ [[ -e "$OUT" ]] && die "already exists: $OUT (delete it by hand if you want to regenerate it)"
67
+
68
+ sed \
69
+ -e "s#__AGENT_NAME__#$AGENT_NAME#g" \
70
+ -e "s#__AGENT_SHORT__#$SHORT#g" \
71
+ -e "s#__AGENT_DESCRIPTION__#$DESCRIPTION#g" \
72
+ -e "s#__PROJECT_LABEL__#$LABEL#g" \
73
+ -e "s#__TASK_SH_PATH__#$TASK_SH#g" \
74
+ "$ROOT_DIR/templates/tm-custom.md.tmpl" > "$OUT"
75
+
76
+ echo "created: $OUT"
77
+ echo "(--as/assign value: \"$SHORT\" — edit the file if you need to refine its role/scope)"
package/bin/ctm ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # ctm — the claude-task-manager command-line entry point.
4
+ #
5
+ # This file typically runs as a SYMLINK from a directory on PATH (e.g. /usr/local/bin/ctm or
6
+ # ~/.local/bin/ctm) — install.sh registers it automatically. Callable from any project
7
+ # directory, no cd/absolute path needed.
8
+ #
9
+ # Usage:
10
+ # ctm init [project-id] [label] Install the CURRENT directory (or its git root)
11
+ # ctm list Registered projects
12
+ # ctm rm <id> [--force] Remove a registered project (data + wrapper) — asks first
13
+ # ctm wrapper <id> Print a project's generated wrapper task.sh
14
+ # ctm agent add <name> [desc] Create a custom ("tm-<name>") teammate definition
15
+ # ctm up [port] Start/check the board with docker; if running, only
16
+ # reconciles; if a port is given, sets it (writes .env)
17
+ # and restarts the container. Reports a busy port before
18
+ # calling docker at all.
19
+ # ctm down Stop the board.
20
+ # ctm autostart on|off Auto-start the container on Docker/machine restart
21
+ # (docker restart-policy) — sets and applies it.
22
+
23
+ set -euo pipefail
24
+
25
+ # Resolve the real file behind the symlink (portable, works on macOS too — BSD readlink
26
+ # doesn't support -f).
27
+ SOURCE="${BASH_SOURCE[0]}"
28
+ while [[ -L "$SOURCE" ]]; do
29
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
30
+ SOURCE="$(readlink "$SOURCE")"
31
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
32
+ done
33
+ BIN_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
34
+ ROOT_DIR="$(cd "$BIN_DIR/.." && pwd)"
35
+
36
+ die() { echo "error: $*" >&2; exit 1; }
37
+
38
+ # shellcheck source=engine/check-update.sh
39
+ source "$ROOT_DIR/engine/check-update.sh"
40
+ check_for_updates "$ROOT_DIR"
41
+
42
+ # docker-compose.yml interpolates the .env CTM_PORT value from the project root into the
43
+ # ports/command fields — this is where we write the desired port, if the caller gave one.
44
+ set_env_var() {
45
+ local file="$1" key="$2" value="$3" tmp
46
+ tmp="$(mktemp)"
47
+ [[ -f "$file" ]] && grep -v "^${key}=" "$file" > "$tmp" || true
48
+ echo "${key}=${value}" >> "$tmp"
49
+ mv "$tmp" "$file"
50
+ }
51
+ current_port() {
52
+ grep '^CTM_PORT=' "$ROOT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2
53
+ }
54
+
55
+ # True if OUR OWN container (claude-task-manager) is already running and already listening
56
+ # on this port — in that case there's no conflict, docker compose will just reconcile.
57
+ our_container_already_on_port() {
58
+ local port="$1"
59
+ docker ps --filter "name=^claude-task-manager$" --format '{{.Ports}}' 2>/dev/null \
60
+ | grep -q "127.0.0.1:${port}->"
61
+ }
62
+
63
+ # If the requested port is already taken by a DIFFERENT process, fail clearly before even
64
+ # calling docker compose (its own error message is less helpful here).
65
+ check_port_free() {
66
+ local port="$1"
67
+ our_container_already_on_port "$port" && return 0
68
+ if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
69
+ die "port $port is already in use (by another process) — pick a different one: ctm up <other-port>"
70
+ fi
71
+ }
72
+
73
+ cmd="${1:-help}"; shift || true
74
+
75
+ case "$cmd" in
76
+ init)
77
+ "$ROOT_DIR/install.sh" "$(pwd)" "$@"
78
+ ;;
79
+ list|ls)
80
+ "$ROOT_DIR/engine/projects.sh" list
81
+ ;;
82
+ rm|remove)
83
+ id="" force=0
84
+ for a in "$@"; do
85
+ case "$a" in
86
+ --force|-y|--yes) force=1 ;;
87
+ *) id="$a" ;;
88
+ esac
89
+ done
90
+ [[ -n "$id" ]] || die "usage: ctm rm <id> [--force]"
91
+ if [[ "$force" != "1" ]]; then
92
+ if [[ ! -t 0 ]]; then
93
+ die "project \"$id\" not removed — non-interactive session, pass --force to confirm"
94
+ fi
95
+ reply=""
96
+ read -r -p "Remove project \"$id\" (data + wrapper, cannot be undone)? [y/N] " reply || reply="n"
97
+ [[ "$reply" =~ ^[Yy]$ ]] || { echo "cancelled"; exit 0; }
98
+ fi
99
+ "$ROOT_DIR/engine/projects.sh" rm "$id"
100
+ ;;
101
+ wrapper)
102
+ "$ROOT_DIR/engine/projects.sh" wrapper "$@"
103
+ ;;
104
+ agent)
105
+ sub="${1:-}"; shift || true
106
+ case "$sub" in
107
+ add) "$ROOT_DIR/bin/add-agent.sh" "$(pwd)" "$@" ;;
108
+ *) die "usage: ctm agent add <name> [description]" ;;
109
+ esac
110
+ ;;
111
+ up)
112
+ port="${1:-$(current_port)}"
113
+ port="${port:-3333}"
114
+ [[ "$port" =~ ^[0-9]+$ ]] || die "invalid port: $port"
115
+ check_port_free "$port"
116
+ set_env_var "$ROOT_DIR/.env" "CTM_PORT" "$port"
117
+ # `docker compose up -d` is itself idempotent: if the container isn't running, it starts
118
+ # it; if it's running and the config (e.g. port) changed, it's automatically recreated
119
+ # with the new setting; if it's running and nothing changed, it does nothing.
120
+ (cd "$ROOT_DIR" && docker compose up -d --build)
121
+ echo "board: http://localhost:$(current_port)/"
122
+ ;;
123
+ down)
124
+ (cd "$ROOT_DIR" && docker compose down)
125
+ ;;
126
+ autostart)
127
+ mode="${1:-}"
128
+ case "$mode" in
129
+ on) set_env_var "$ROOT_DIR/.env" "CTM_RESTART" "unless-stopped" ;;
130
+ off) set_env_var "$ROOT_DIR/.env" "CTM_RESTART" "no" ;;
131
+ *) die "usage: ctm autostart on|off" ;;
132
+ esac
133
+ (cd "$ROOT_DIR" && docker compose up -d --build)
134
+ echo "autostart: $mode (restart-policy: $(grep '^CTM_RESTART=' "$ROOT_DIR/.env" | cut -d= -f2))"
135
+ ;;
136
+ help|-h|--help|"")
137
+ cat <<'EOF'
138
+ ctm — claude-task-manager CLI
139
+
140
+ ctm init [project-id] [label] [--force]
141
+ Register the CURRENT directory (or its git root) as a
142
+ project + install .claude/skills/task-manager/ (task.sh
143
+ wrapper + SKILL.md + ctm-* agents + hooks + allowlist).
144
+ Docker NOT required. Prompts before overwriting an
145
+ existing file (--force / -y skips the prompt).
146
+ ctm list List registered projects.
147
+ ctm rm <id> [--force] Remove a registered project (data + wrapper). Asks for
148
+ confirmation first (or requires --force non-interactively).
149
+ ctm wrapper <id> Print a project's generated wrapper task.sh.
150
+ ctm agent add <name> [desc] Create a custom teammate definition in the CURRENT
151
+ (already "ctm init"-ed) project — always named "tm-<name>".
152
+ ctm up [port] Check/start the board with docker. Starts it if not
153
+ running; does nothing if already running; if you also give
154
+ a port, sets it and restarts if needed.
155
+ ctm down Stop the board.
156
+ ctm autostart on|off Auto-start the container on Docker/machine restart.
157
+ EOF
158
+ ;;
159
+ *) die "unknown command: $cmd (help: ctm help)" ;;
160
+ esac
@@ -0,0 +1,14 @@
1
+ services:
2
+ task-manager:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ container_name: claude-task-manager
7
+ restart: ${CTM_RESTART:-no} # "unless-stopped"-re állítva: ctm autostart on
8
+ ports:
9
+ - "127.0.0.1:${CTM_PORT:-3333}:${CTM_PORT:-3333}" # csak a host loopbackjén, ne a LAN-on
10
+ volumes:
11
+ - .:/app
12
+ environment:
13
+ - PHP_CLI_SERVER_WORKERS=4
14
+ command: ["sh", "-c", "php -S 0.0.0.0:${CTM_PORT:-3333} -t /app"]
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-update.sh — sourced (not executed) by the other scripts to print a yellow notice
4
+ # when origin's default branch has commits the local checkout doesn't have yet.
5
+ #
6
+ # Deliberately NOT sourced by engine/task.sh: that script is called on every single task
7
+ # mutation (often many times per agent session), and a network round-trip on every call
8
+ # would add real, repeated latency to the hot path. It IS sourced by the admin-facing
9
+ # entry points (ctm, install.sh, add-agent.sh, projects.sh), which run far less often.
10
+ #
11
+ # IMPORTANT: this is sourced into callers that run under `set -e` — every command that
12
+ # could plausibly fail (offline, no origin/HEAD set, first commit not yet pushed, etc.)
13
+ # is guarded with `|| true` so a failure here can NEVER abort the calling script.
14
+ #
15
+ # Usage (from a script that has already resolved ROOT_DIR to the repo root):
16
+ # source "$ROOT_DIR/engine/check-update.sh"
17
+ # check_for_updates "$ROOT_DIR"
18
+ #
19
+ # Silent on: no git, not a git checkout, no "origin" remote, offline/unreachable remote,
20
+ # or local already up to date. Only prints when the remote genuinely has something new.
21
+
22
+ check_for_updates() {
23
+ local root="${1:-.}"
24
+ command -v git >/dev/null 2>&1 || return 0
25
+ [[ -d "$root/.git" ]] || return 0
26
+ git -C "$root" remote get-url origin >/dev/null 2>&1 || return 0
27
+
28
+ # origin/HEAD may not be set locally (git push -u doesn't set it the way git clone does)
29
+ # — fall back to "main" when it's missing, rather than letting the lookup fail.
30
+ local branch
31
+ branch="$(git -C "$root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
32
+ branch="${branch#origin/}"
33
+ branch="${branch:-main}"
34
+
35
+ # Lightweight ref lookup (no object transfer) with a short low-speed timeout, so a slow
36
+ # or unreachable remote never noticeably delays the command that triggered this check.
37
+ local remote_sha local_sha
38
+ remote_sha="$( (git -C "$root" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=2 \
39
+ ls-remote origin "refs/heads/$branch" 2>/dev/null || true) | cut -f1)"
40
+ [[ -n "$remote_sha" ]] || return 0
41
+ local_sha="$(git -C "$root" rev-parse HEAD 2>/dev/null || true)"
42
+ [[ -n "$local_sha" ]] || return 0
43
+ [[ "$remote_sha" == "$local_sha" ]] && return 0
44
+
45
+ printf '\033[33m[claude-task-manager] A newer version is available on origin/%s — update with: git -C %s pull\033[0m\n' \
46
+ "$branch" "$root" >&2
47
+ return 0
48
+ }
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # projects.sh — project-registration admin CLI for claude-task-manager.
4
+ #
5
+ # For every registered project, creates its own data directory (data/<id>/tasks.json etc.,
6
+ # using the existing task.sh init) and an absolute-path wrapper script with the project id
7
+ # "baked in" (wrappers/<id>.sh), which can be copied into the target project so its agents
8
+ # can call it directly, without needing TM_DIR.
9
+ #
10
+ # Usage:
11
+ # ./projects.sh add <id> <label>
12
+ # ./projects.sh list
13
+ # ./projects.sh rm <id>
14
+ # ./projects.sh wrapper <id>
15
+
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
+
21
+ DATA_ROOT="$ROOT_DIR/data"
22
+ WRAPPERS_DIR="$ROOT_DIR/wrappers"
23
+ ENGINE_TASK_SH="$ROOT_DIR/engine/task.sh"
24
+ PROJECTS_FILE="$DATA_ROOT/projects.json"
25
+ LOCK_DIR="$DATA_ROOT/.projects.lock"
26
+
27
+ # shellcheck source=engine/check-update.sh
28
+ source "$ROOT_DIR/engine/check-update.sh"
29
+ check_for_updates "$ROOT_DIR"
30
+
31
+ die() { echo "error: $*" >&2; exit 1; }
32
+
33
+ command -v jq >/dev/null 2>&1 || die "jq is not installed (required for this script)."
34
+
35
+ now_iso() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
36
+
37
+ is_valid_id() {
38
+ [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]
39
+ }
40
+
41
+ # --- Concurrency lock (same mkdir-based pattern as the engine task.sh) --------------
42
+ LOCK_HELD=0
43
+ acquire_lock() {
44
+ local waited=0 timeout=15
45
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
46
+ waited=$((waited + 1))
47
+ [[ $waited -ge $((timeout * 20)) ]] && die "could not acquire lock within ${timeout}s: $LOCK_DIR"
48
+ sleep 0.05
49
+ done
50
+ LOCK_HELD=1
51
+ trap release_lock EXIT INT TERM
52
+ }
53
+ release_lock() {
54
+ [[ "$LOCK_HELD" == "1" ]] && rm -rf "$LOCK_DIR" 2>/dev/null || true
55
+ LOCK_HELD=0
56
+ }
57
+
58
+ ensure_projects_file() {
59
+ mkdir -p "$DATA_ROOT"
60
+ [[ -f "$PROJECTS_FILE" ]] || echo '[]' > "$PROJECTS_FILE"
61
+ }
62
+
63
+ project_exists() {
64
+ local id="$1"
65
+ [[ "$(jq --arg id "$id" '[.[]|select(.id==$id)]|length' "$PROJECTS_FILE")" != "0" ]]
66
+ }
67
+
68
+ # Atomic write to PROJECTS_FILE (jq filter, via a temp file).
69
+ apply_jq() {
70
+ acquire_lock
71
+ local tmp
72
+ tmp="$(mktemp "${PROJECTS_FILE}.XXXXXX")"
73
+ if jq "$@" "$PROJECTS_FILE" > "$tmp"; then
74
+ mv "$tmp" "$PROJECTS_FILE"
75
+ else
76
+ rm -f "$tmp"
77
+ release_lock
78
+ die "jq operation failed."
79
+ fi
80
+ release_lock
81
+ }
82
+
83
+ wrapper_path() { echo "$WRAPPERS_DIR/$1.sh"; }
84
+ data_dir() { echo "$DATA_ROOT/$1"; }
85
+
86
+ write_wrapper() {
87
+ local id="$1" label="$2" dataDir="$3" wpath="$4"
88
+ mkdir -p "$WRAPPERS_DIR"
89
+ cat > "$wpath" <<EOF
90
+ #!/usr/bin/env bash
91
+ # Auto-generated for the claude-task-manager "${label}" project — do not edit by hand.
92
+ # Regenerate with: engine/projects.sh add ${id} "${label}" (overwrites)
93
+ exec env TM_DIR="${dataDir}" \\
94
+ "${ENGINE_TASK_SH}" "\$@"
95
+ EOF
96
+ chmod +x "$wpath"
97
+ }
98
+
99
+ cmd_add() {
100
+ [[ $# -ge 2 ]] || die "usage: add <id> <label>"
101
+ local id="$1" label="$2"
102
+ is_valid_id "$id" || die "invalid id (only A-Za-z0-9_- allowed): $id"
103
+ ensure_projects_file
104
+ local dataDir wpath now
105
+ dataDir="$(data_dir "$id")"
106
+ wpath="$(wrapper_path "$id")"
107
+ now="$(now_iso)"
108
+
109
+ mkdir -p "$dataDir"
110
+ TM_DIR="$dataDir" "$ENGINE_TASK_SH" init >/dev/null
111
+ TM_DIR="$dataDir" "$ENGINE_TASK_SH" ctx-init "" "" --as projects-admin >/dev/null
112
+
113
+ write_wrapper "$id" "$label" "$dataDir" "$wpath"
114
+
115
+ if project_exists "$id"; then
116
+ apply_jq --arg id "$id" --arg label "$label" --arg dataDir "$dataDir" \
117
+ --arg wpath "$wpath" --arg now "$now" \
118
+ '(.[]|select(.id==$id)) |= (.label=$label | .dataDir=$dataDir | .wrapperPath=$wpath)'
119
+ echo "updated: $id"
120
+ else
121
+ apply_jq --arg id "$id" --arg label "$label" --arg dataDir "$dataDir" \
122
+ --arg wpath "$wpath" --arg now "$now" \
123
+ '. += [{id:$id, label:$label, dataDir:$dataDir, wrapperPath:$wpath, createdAt:$now}]'
124
+ echo "added: $id -> $dataDir (wrapper: $wpath)"
125
+ fi
126
+ }
127
+
128
+ cmd_list() {
129
+ ensure_projects_file
130
+ jq -r '.[] | "\(.id)\t\(.label)\t\(.dataDir)"' "$PROJECTS_FILE" | column -t -s $'\t'
131
+ }
132
+
133
+ cmd_rm() {
134
+ [[ $# -ge 1 ]] || die "usage: rm <id>"
135
+ local id="$1"
136
+ ensure_projects_file
137
+ project_exists "$id" || die "no such project: $id"
138
+ apply_jq --arg id "$id" '[.[]|select(.id!=$id)]'
139
+ rm -rf "$(data_dir "$id")"
140
+ rm -f "$(wrapper_path "$id")"
141
+ echo "removed: $id (data and wrapper too)"
142
+ }
143
+
144
+ cmd_wrapper() {
145
+ [[ $# -ge 1 ]] || die "usage: wrapper <id>"
146
+ local id="$1"
147
+ ensure_projects_file
148
+ project_exists "$id" || die "no such project: $id"
149
+ cat "$(wrapper_path "$id")"
150
+ }
151
+
152
+ main() {
153
+ local cmd="${1:-}"
154
+ shift || true
155
+ case "$cmd" in
156
+ add) cmd_add "$@" ;;
157
+ list|ls) cmd_list "$@" ;;
158
+ rm|remove) cmd_rm "$@" ;;
159
+ wrapper) cmd_wrapper "$@" ;;
160
+ *) die "unknown command: $cmd (add|list|rm|wrapper)" ;;
161
+ esac
162
+ }
163
+
164
+ main "$@"