@cairnkeep/cli 2.0.0 → 2.2.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/CHANGELOG.md +33 -0
- package/README.md +20 -0
- package/containers/compose.yaml +29 -0
- package/containers/entrypoint.sh +54 -0
- package/containers/healthcheck.mjs +46 -0
- package/containers/quadlet/cairnkeep-data.volume +2 -0
- package/containers/quadlet/cairnkeep.container +32 -0
- package/containers/workspace-entrypoint.sh +25 -0
- package/docs/containers.md +187 -0
- package/docs/operating.md +14 -0
- package/docs/overlay-distributions.md +8 -0
- package/docs/privacy-and-data-flow.md +6 -0
- package/docs/releasing.md +19 -4
- package/docs/storage.md +31 -0
- package/mcp-memory-server/dist/embeddings.js +7 -2
- package/mcp-memory-server/dist/index.js +245 -8
- package/mcp-memory-server/package.json +2 -1
- package/package.json +4 -2
- package/scripts/cairn-container +224 -0
- package/scripts/doctor.sh +16 -3
- package/scripts/probe-container-memory.mjs +51 -0
- package/scripts/test-container-runner.sh +83 -0
- package/scripts/test-container.sh +183 -0
- package/scripts/test-doctor.sh +62 -2
- package/templates/env.example.template +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
tmp=$(mktemp -d)
|
|
6
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
7
|
+
|
|
8
|
+
capture=$tmp/args
|
|
9
|
+
engine=$tmp/podman
|
|
10
|
+
repo=$tmp/repository
|
|
11
|
+
token=$tmp/token
|
|
12
|
+
env_file=$tmp/container.env
|
|
13
|
+
mkdir -p "$repo"
|
|
14
|
+
printf 'token\n' > "$token"
|
|
15
|
+
printf 'CAIRN_MEMORY_EMBEDDING_MODEL=example\n' > "$env_file"
|
|
16
|
+
chmod 600 "$token"
|
|
17
|
+
|
|
18
|
+
cat > "$engine" <<'EOF'
|
|
19
|
+
#!/bin/sh
|
|
20
|
+
printf '%s\n' "$@" > "$CAIRN_CONTAINER_CAPTURE"
|
|
21
|
+
EOF
|
|
22
|
+
chmod 755 "$engine"
|
|
23
|
+
|
|
24
|
+
run_launcher() {
|
|
25
|
+
CAIRN_CONTAINER_CAPTURE=$capture CONTAINER_ENGINE=$engine \
|
|
26
|
+
"$ROOT/scripts/cairn-container" "$@"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
has_arg() {
|
|
30
|
+
grep -Fqx -- "$1" "$capture" || {
|
|
31
|
+
echo "FAIL: missing container argument: $1" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lacks_arg() {
|
|
37
|
+
if grep -Fqx -- "$1" "$capture"; then
|
|
38
|
+
echo "FAIL: unexpected container argument: $1" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run_launcher stdio --image example/server:1 --volume example-data
|
|
44
|
+
has_arg --userns=keep-id:uid=10001,gid=10001
|
|
45
|
+
has_arg --read-only
|
|
46
|
+
has_arg --cap-drop=all
|
|
47
|
+
has_arg example-data:/data:Z,U
|
|
48
|
+
has_arg example/server:1
|
|
49
|
+
has_arg stdio
|
|
50
|
+
|
|
51
|
+
run_launcher http --image example/server:1 --token-file "$token" --port 8123 \
|
|
52
|
+
--allowed-hosts memory.example.com --env-file "$env_file"
|
|
53
|
+
has_arg --detach
|
|
54
|
+
has_arg 127.0.0.1:8123:7801
|
|
55
|
+
has_arg "$token:/run/secrets/http-token:ro,Z"
|
|
56
|
+
has_arg CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=memory.example.com
|
|
57
|
+
has_arg CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token
|
|
58
|
+
has_arg "$env_file"
|
|
59
|
+
|
|
60
|
+
chmod 644 "$token"
|
|
61
|
+
if run_launcher http --token-file "$token" >"$tmp/out" 2>"$tmp/err"; then
|
|
62
|
+
echo "FAIL: permissive token file was accepted" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
grep -Fq "mode 400 or 600" "$tmp/err"
|
|
66
|
+
chmod 600 "$token"
|
|
67
|
+
|
|
68
|
+
run_launcher workspace --repo "$repo" --image example/workspace:1 \
|
|
69
|
+
--workspace-volume example-workspace -- echo sandbox
|
|
70
|
+
has_arg "$repo:/source:ro,Z"
|
|
71
|
+
has_arg example-workspace:/workspace:Z,U
|
|
72
|
+
has_arg CAIRN_WORKSPACE_MODE=sandbox
|
|
73
|
+
lacks_arg "$repo:/workspace:rw,Z"
|
|
74
|
+
has_arg echo
|
|
75
|
+
has_arg sandbox
|
|
76
|
+
|
|
77
|
+
run_launcher workspace --repo "$repo" --mode shared --image example/workspace:1
|
|
78
|
+
has_arg "$repo:/workspace:rw,Z"
|
|
79
|
+
has_arg CAIRN_WORKSPACE_MODE=shared
|
|
80
|
+
lacks_arg "$repo:/source:ro,Z"
|
|
81
|
+
has_arg bash
|
|
82
|
+
|
|
83
|
+
echo "PASS: container launcher security and workspace contracts"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
if [[ "${CAIRN_TEST_CONTAINERS:-0}" != 1 ]]; then
|
|
5
|
+
echo "SKIP: container integration (set CAIRN_TEST_CONTAINERS=1 to run)"
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
10
|
+
ENGINE=${CONTAINER_ENGINE:-}
|
|
11
|
+
if [[ -z "$ENGINE" ]]; then
|
|
12
|
+
if command -v podman >/dev/null 2>&1; then ENGINE=podman
|
|
13
|
+
elif command -v docker >/dev/null 2>&1; then ENGINE=docker
|
|
14
|
+
else echo "FAIL: Podman or Docker is required" >&2; exit 1
|
|
15
|
+
fi
|
|
16
|
+
fi
|
|
17
|
+
command -v "$ENGINE" >/dev/null 2>&1 || { echo "FAIL: container engine not found: $ENGINE" >&2; exit 1; }
|
|
18
|
+
|
|
19
|
+
suffix=$$
|
|
20
|
+
server_image="localhost/cairnkeep-test-server:$suffix"
|
|
21
|
+
workspace_image="localhost/cairnkeep-test-workspace:$suffix"
|
|
22
|
+
volume="cairnkeep-test-data-$suffix"
|
|
23
|
+
sandbox_volume="cairnkeep-test-workspace-$suffix"
|
|
24
|
+
name="cairnkeep-test-$suffix"
|
|
25
|
+
tmp=$(mktemp -d)
|
|
26
|
+
|
|
27
|
+
cleanup() {
|
|
28
|
+
"$ENGINE" rm -f "$name" >/dev/null 2>&1 || true
|
|
29
|
+
"$ENGINE" volume rm -f "$volume" "$sandbox_volume" >/dev/null 2>&1 || true
|
|
30
|
+
"$ENGINE" image rm -f "$server_image" "$workspace_image" >/dev/null 2>&1 || true
|
|
31
|
+
rm -rf "$tmp"
|
|
32
|
+
}
|
|
33
|
+
trap cleanup EXIT
|
|
34
|
+
|
|
35
|
+
cd "$ROOT"
|
|
36
|
+
"$ENGINE" build --target server -t "$server_image" -f Containerfile .
|
|
37
|
+
"$ENGINE" build --target workspace -t "$workspace_image" -f Containerfile .
|
|
38
|
+
|
|
39
|
+
image_user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$server_image")
|
|
40
|
+
[[ "$image_user" == "10001:10001" ]] || {
|
|
41
|
+
echo "FAIL: server image must run as 10001:10001, got $image_user" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
"$ENGINE" volume create "$volume" >/dev/null
|
|
46
|
+
node scripts/probe-memory-server.mjs --command \
|
|
47
|
+
"$ENGINE" run --rm -i --volume "$volume:/data" "$server_image" stdio
|
|
48
|
+
|
|
49
|
+
if "$ENGINE" run --rm "$server_image" http >/dev/null 2>&1; then
|
|
50
|
+
echo "FAIL: HTTP mode started without a token" >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
token=container-smoke-token
|
|
55
|
+
printf '%s' "$token" > "$tmp/http-token"
|
|
56
|
+
# Docker bind mounts preserve the host UID; world-read is acceptable for this
|
|
57
|
+
# non-secret fixture. The real Podman launcher requires mode 400 or 600.
|
|
58
|
+
chmod 644 "$tmp/http-token"
|
|
59
|
+
port=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')
|
|
60
|
+
|
|
61
|
+
start_http() {
|
|
62
|
+
"$ENGINE" run --detach --name "$name" \
|
|
63
|
+
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
64
|
+
--tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
65
|
+
--publish "127.0.0.1:${port}:7801" \
|
|
66
|
+
--volume "$volume:/data" \
|
|
67
|
+
--volume "$tmp/http-token:/run/secrets/http-token:ro" \
|
|
68
|
+
--env MCP_HTTP_HOST=0.0.0.0 \
|
|
69
|
+
--env MCP_HTTP_PORT=7801 \
|
|
70
|
+
--env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
|
|
71
|
+
--env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
|
|
72
|
+
"$server_image" http >/dev/null
|
|
73
|
+
|
|
74
|
+
for _ in $(seq 1 50); do
|
|
75
|
+
if node scripts/probe-container-memory.mjs read "http://127.0.0.1:${port}/mcp" "$token" \
|
|
76
|
+
>"$tmp/read.out" 2>"$tmp/read.err"; then
|
|
77
|
+
return 0
|
|
78
|
+
fi
|
|
79
|
+
if ! "$ENGINE" inspect "$name" >/dev/null 2>&1; then break; fi
|
|
80
|
+
sleep 0.2
|
|
81
|
+
done
|
|
82
|
+
return 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# First boot: wait for MCP, then write the canary.
|
|
86
|
+
"$ENGINE" run --detach --name "$name" \
|
|
87
|
+
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
88
|
+
--tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
89
|
+
--publish "127.0.0.1:${port}:7801" \
|
|
90
|
+
--volume "$volume:/data" \
|
|
91
|
+
--volume "$tmp/http-token:/run/secrets/http-token:ro" \
|
|
92
|
+
--env MCP_HTTP_HOST=0.0.0.0 \
|
|
93
|
+
--env MCP_HTTP_PORT=7801 \
|
|
94
|
+
--env "CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=127.0.0.1:${port}" \
|
|
95
|
+
--env CAIRN_MEMORY_HTTP_TOKEN_FILE=/run/secrets/http-token \
|
|
96
|
+
"$server_image" http >/dev/null
|
|
97
|
+
written=0
|
|
98
|
+
for _ in $(seq 1 50); do
|
|
99
|
+
if node scripts/probe-container-memory.mjs write "http://127.0.0.1:${port}/mcp" "$token" \
|
|
100
|
+
>"$tmp/write.out" 2>"$tmp/write.err"; then
|
|
101
|
+
written=1
|
|
102
|
+
break
|
|
103
|
+
fi
|
|
104
|
+
sleep 0.2
|
|
105
|
+
done
|
|
106
|
+
[[ "$written" == 1 ]] || {
|
|
107
|
+
cat "$tmp/write.err" >&2
|
|
108
|
+
echo "FAIL: container HTTP server did not become ready" >&2
|
|
109
|
+
exit 1
|
|
110
|
+
}
|
|
111
|
+
cat "$tmp/write.out"
|
|
112
|
+
"$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs
|
|
113
|
+
"$ENGINE" rm -f "$name" >/dev/null
|
|
114
|
+
|
|
115
|
+
# Replacement boot with the same volume must recover the canary.
|
|
116
|
+
start_http || {
|
|
117
|
+
cat "$tmp/read.err" >&2
|
|
118
|
+
echo "FAIL: persisted memory was not readable after replacement" >&2
|
|
119
|
+
exit 1
|
|
120
|
+
}
|
|
121
|
+
cat "$tmp/read.out"
|
|
122
|
+
"$ENGINE" rm -f "$name" >/dev/null
|
|
123
|
+
|
|
124
|
+
mkdir -p "$tmp/repo"
|
|
125
|
+
printf 'source\n' > "$tmp/repo/source.txt"
|
|
126
|
+
|
|
127
|
+
# Shared mode deliberately writes through to the selected host repository.
|
|
128
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
129
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
130
|
+
--volume "$tmp/repo:/workspace:Z" \
|
|
131
|
+
--env CAIRN_WORKSPACE_MODE=shared \
|
|
132
|
+
"$workspace_image" sh -c 'printf shared > shared.txt'
|
|
133
|
+
else
|
|
134
|
+
chmod 755 "$tmp"
|
|
135
|
+
chmod 777 "$tmp/repo"
|
|
136
|
+
chmod 666 "$tmp/repo/source.txt"
|
|
137
|
+
"$ENGINE" run --rm \
|
|
138
|
+
--volume "$tmp/repo:/workspace" \
|
|
139
|
+
--env CAIRN_WORKSPACE_MODE=shared \
|
|
140
|
+
"$workspace_image" sh -c 'printf shared > shared.txt'
|
|
141
|
+
fi
|
|
142
|
+
[[ $(cat "$tmp/repo/shared.txt") == shared ]] || {
|
|
143
|
+
echo "FAIL: shared workspace did not write through" >&2
|
|
144
|
+
exit 1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Prepare a writable named volume for the image's unprivileged runtime user.
|
|
148
|
+
"$ENGINE" volume create "$sandbox_volume" >/dev/null
|
|
149
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
150
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
151
|
+
--volume "$tmp/repo:/source:ro,Z" \
|
|
152
|
+
--volume "$sandbox_volume:/workspace:Z,U" \
|
|
153
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
154
|
+
"$workspace_image" sh -c 'printf sandbox > sandbox.txt'
|
|
155
|
+
else
|
|
156
|
+
"$ENGINE" run --rm --user root --entrypoint sh \
|
|
157
|
+
--volume "$sandbox_volume:/workspace" "$workspace_image" \
|
|
158
|
+
-c 'chown 10001:10001 /workspace'
|
|
159
|
+
"$ENGINE" run --rm \
|
|
160
|
+
--volume "$tmp/repo:/source:ro" \
|
|
161
|
+
--volume "$sandbox_volume:/workspace" \
|
|
162
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
163
|
+
"$workspace_image" sh -c 'printf sandbox > sandbox.txt'
|
|
164
|
+
fi
|
|
165
|
+
[[ ! -e "$tmp/repo/sandbox.txt" ]] || {
|
|
166
|
+
echo "FAIL: sandbox mode modified the host repository" >&2
|
|
167
|
+
exit 1
|
|
168
|
+
}
|
|
169
|
+
if [[ "$ENGINE" == podman ]]; then
|
|
170
|
+
"$ENGINE" run --rm --userns=keep-id:uid=10001,gid=10001 \
|
|
171
|
+
--volume "$tmp/repo:/source:ro,Z" \
|
|
172
|
+
--volume "$sandbox_volume:/workspace:Z,U" \
|
|
173
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
174
|
+
"$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
|
|
175
|
+
else
|
|
176
|
+
"$ENGINE" run --rm \
|
|
177
|
+
--volume "$tmp/repo:/source:ro" \
|
|
178
|
+
--volume "$sandbox_volume:/workspace" \
|
|
179
|
+
--env CAIRN_WORKSPACE_MODE=sandbox \
|
|
180
|
+
"$workspace_image" sh -c 'test "$(cat sandbox.txt)" = sandbox'
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
echo "PASS: container stdio, guarded HTTP, persistence, shared, and sandbox modes"
|
package/scripts/test-doctor.sh
CHANGED
|
@@ -5,7 +5,12 @@ set -uo pipefail
|
|
|
5
5
|
|
|
6
6
|
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
7
7
|
tmp=$(mktemp -d)
|
|
8
|
-
|
|
8
|
+
server_pid=""
|
|
9
|
+
cleanup() {
|
|
10
|
+
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null || true
|
|
11
|
+
rm -rf "$tmp"
|
|
12
|
+
}
|
|
13
|
+
trap cleanup EXIT
|
|
9
14
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
10
15
|
|
|
11
16
|
doctor="$ROOT/scripts/doctor.sh"
|
|
@@ -46,4 +51,59 @@ else
|
|
|
46
51
|
echo " (curl absent — skipped the unreachable-endpoint case)"
|
|
47
52
|
fi
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
# 4. The embedding check performs a real authenticated model request.
|
|
55
|
+
if command -v curl >/dev/null 2>&1; then
|
|
56
|
+
cat > "$tmp/embedding-server.mjs" <<'EOF'
|
|
57
|
+
import { createServer } from "node:http";
|
|
58
|
+
import { writeFileSync } from "node:fs";
|
|
59
|
+
|
|
60
|
+
const server = createServer((request, response) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
63
|
+
request.on("end", () => {
|
|
64
|
+
let body = {};
|
|
65
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {}
|
|
66
|
+
const valid = request.url === "/v1/embeddings"
|
|
67
|
+
&& request.headers.authorization === "Bearer test-key"
|
|
68
|
+
&& body.model === "test-embedding";
|
|
69
|
+
response.writeHead(valid ? 200 : 404, { "Content-Type": "application/json" });
|
|
70
|
+
response.end(JSON.stringify(valid
|
|
71
|
+
? { data: [{ index: 0, embedding: [1, 0] }] }
|
|
72
|
+
: { error: "invalid request" }));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
server.listen(0, "127.0.0.1", () => {
|
|
76
|
+
writeFileSync(process.argv[2], String(server.address().port));
|
|
77
|
+
});
|
|
78
|
+
EOF
|
|
79
|
+
node "$tmp/embedding-server.mjs" "$tmp/embedding-port" &
|
|
80
|
+
server_pid=$!
|
|
81
|
+
for _ in $(seq 1 50); do [[ -s "$tmp/embedding-port" ]] && break; sleep 0.1; done
|
|
82
|
+
[[ -s "$tmp/embedding-port" ]] || fail "embedding test server did not start"
|
|
83
|
+
port=$(cat "$tmp/embedding-port")
|
|
84
|
+
|
|
85
|
+
proj3="$tmp/embedding-ok"; mkdir -p "$proj3/.ai"
|
|
86
|
+
cat > "$proj3/.ai/.env" <<EOF
|
|
87
|
+
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
88
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=test-embedding
|
|
89
|
+
CAIRN_LLM_API_KEY=test-key
|
|
90
|
+
EOF
|
|
91
|
+
( cd "$proj3" && "$doctor" ) >"$tmp/out3" 2>&1 ||
|
|
92
|
+
fail "doctor rejected a working embedding endpoint:\n$(cat "$tmp/out3")"
|
|
93
|
+
grep -q "\[PASS\] embedding endpoint accepted model test-embedding" "$tmp/out3" ||
|
|
94
|
+
fail "expected a functional embedding PASS line"
|
|
95
|
+
|
|
96
|
+
proj4="$tmp/embedding-wrong-model"; mkdir -p "$proj4/.ai"
|
|
97
|
+
cat > "$proj4/.ai/.env" <<EOF
|
|
98
|
+
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
99
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=missing-model
|
|
100
|
+
CAIRN_LLM_API_KEY=test-key
|
|
101
|
+
EOF
|
|
102
|
+
if ( cd "$proj4" && "$doctor" ) >"$tmp/out4" 2>&1; then
|
|
103
|
+
fail "doctor should reject a missing embedding model:\n$(cat "$tmp/out4")"
|
|
104
|
+
fi
|
|
105
|
+
grep -q "\[FAIL\] embedding request failed for model missing-model" "$tmp/out4" ||
|
|
106
|
+
fail "expected a functional embedding FAIL line"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
echo "PASS: cairn doctor (skip unconfigured, fail unreachable, probe embeddings)"
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# CAIRN_LLM_API_URL=https://api.openai.com/v1
|
|
8
8
|
# CAIRN_LLM_EXTRACTION_MODEL=gpt-4o-mini
|
|
9
9
|
# CAIRN_MEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
|
10
|
+
# CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS=15000
|
|
10
11
|
#
|
|
11
12
|
# Git host for the collaboration commands (memory-sync, repo-review, security-audit).
|
|
12
13
|
# One of: github | gitlab | codeberg | forgejo | none. See docs/git-providers.md.
|