@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/.env.example +2 -0
- package/Dockerfile +11 -0
- package/LICENSE +21 -0
- package/README.hu.md +255 -0
- package/README.md +253 -0
- package/api/index.php +173 -0
- package/bin/add-agent.sh +77 -0
- package/bin/ctm +160 -0
- package/docker-compose.yml +14 -0
- package/engine/check-update.sh +48 -0
- package/engine/projects.sh +164 -0
- package/engine/task.sh +926 -0
- package/favicon.ico +6 -0
- package/favicon.svg +6 -0
- package/index.html +141 -0
- package/install.sh +231 -0
- package/js/ApiClient.js +63 -0
- package/js/App.js +547 -0
- package/js/BoardView.js +205 -0
- package/js/ContextPanel.js +47 -0
- package/js/ContextStore.js +39 -0
- package/js/ProjectStore.js +27 -0
- package/js/TaskModal.js +112 -0
- package/js/TaskStore.js +73 -0
- package/js/UrlState.js +53 -0
- package/js/Utils.js +202 -0
- package/js/i18n.js +338 -0
- package/js/main.js +3 -0
- package/package.json +85 -0
- package/style.css +396 -0
- package/templates/SKILL.md.tmpl +175 -0
- package/templates/agents/ctm-backend-developer.md.tmpl +53 -0
- package/templates/agents/ctm-code-investigator.md.tmpl +49 -0
- package/templates/agents/ctm-frontend-developer.md.tmpl +53 -0
- package/templates/hooks/allow-task-sh.sh.tmpl +59 -0
- package/templates/hooks/notify-inbox.sh.tmpl +53 -0
- package/templates/tm-custom.md.tmpl +54 -0
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]);
|
package/bin/add-agent.sh
ADDED
|
@@ -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 "$@"
|