create-ironclaws 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.
Files changed (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "nanoclaw",
3
+ "version": "1.2.26",
4
+ "description": "Personal Claude assistant. Lightweight, secure, customizable.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "dev": "tsx src/index.ts",
11
+ "typecheck": "tsc --noEmit",
12
+ "format": "prettier --write \"src/**/*.ts\"",
13
+ "format:fix": "prettier --write \"src/**/*.ts\"",
14
+ "format:check": "prettier --check \"src/**/*.ts\"",
15
+ "prepare": "husky",
16
+ "setup": "tsx setup/index.ts",
17
+ "auth": "tsx src/whatsapp-auth.ts",
18
+ "lint": "eslint src/",
19
+ "lint:fix": "eslint src/ --fix",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest"
22
+ },
23
+ "dependencies": {
24
+ "@onecli-sh/sdk": "^0.2.0",
25
+ "@slack/bolt": "^4.3.0",
26
+ "@slack/types": "^2.15.0",
27
+ "better-sqlite3": "11.10.0",
28
+ "cron-parser": "5.5.0",
29
+ "pino": "^9.6.0",
30
+ "pino-pretty": "^13.0.0",
31
+ "yaml": "^2.8.2",
32
+ "zod": "^4.3.6"
33
+ },
34
+ "devDependencies": {
35
+ "@eslint/js": "^9.35.0",
36
+ "@types/better-sqlite3": "^7.6.12",
37
+ "@types/node": "^22.10.0",
38
+ "@vitest/coverage-v8": "^4.0.18",
39
+ "eslint": "^9.35.0",
40
+ "eslint-plugin-no-catch-all": "^1.1.0",
41
+ "globals": "^15.12.0",
42
+ "husky": "^9.1.7",
43
+ "prettier": "^3.8.1",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.7.0",
46
+ "typescript-eslint": "^8.35.0",
47
+ "vitest": "^4.0.18"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ }
52
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate a GitHub App installation token for Argus.
4
+ Prints the token to stdout so it can be captured by the caller.
5
+
6
+ Usage: python3 scripts/github-app-token.py
7
+ """
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import urllib.request
13
+
14
+ import jwt
15
+
16
+ APP_ID = os.environ.get("GITHUB_APP_ID", "3224445")
17
+ INSTALLATION_ID = os.environ.get("GITHUB_APP_INSTALLATION_ID", "120099588")
18
+ PEM_PATH = os.environ.get(
19
+ "GITHUB_APP_PRIVATE_KEY_PATH",
20
+ os.path.join(os.path.dirname(__file__), "..", "github-app.pem"),
21
+ )
22
+
23
+
24
+ def generate_jwt() -> str:
25
+ with open(PEM_PATH) as f:
26
+ private_key = f.read()
27
+ payload = {
28
+ "iat": int(time.time()) - 60,
29
+ "exp": int(time.time()) + 600,
30
+ "iss": APP_ID,
31
+ }
32
+ return jwt.encode(payload, private_key, algorithm="RS256")
33
+
34
+
35
+ def get_installation_token(jwt_token: str) -> str:
36
+ url = f"https://api.github.com/app/installations/{INSTALLATION_ID}/access_tokens"
37
+ req = urllib.request.Request(
38
+ url,
39
+ method="POST",
40
+ headers={
41
+ "Authorization": f"Bearer {jwt_token}",
42
+ "Accept": "application/vnd.github+json",
43
+ "X-GitHub-Api-Version": "2022-11-28",
44
+ },
45
+ )
46
+ with urllib.request.urlopen(req) as r:
47
+ data = json.loads(r.read())
48
+ return data["token"]
49
+
50
+
51
+ if __name__ == "__main__":
52
+ try:
53
+ jwt_token = generate_jwt()
54
+ token = get_installation_token(jwt_token)
55
+ print(token)
56
+ except Exception as e:
57
+ print(f"ERROR: {e}", file=sys.stderr)
58
+ sys.exit(1)
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ # register-expense-agent.sh
3
+ #
4
+ # One-command setup for the expense-policy-checker agent.
5
+ # Run this ONCE after you have:
6
+ # 1. Created the private Slack channel and added Linn + Elisabeth
7
+ # 2. Added Expensify credentials to .env
8
+ # 3. Ran sync-to-vm.sh to push the latest files
9
+ #
10
+ # Usage (on VM):
11
+ # EXPENSE_CHANNEL_ID=C... bash scripts/register-expense-agent.sh
12
+ #
13
+ # Or set directly:
14
+ # EXPENSE_CHANNEL_ID=C0987654321 bash scripts/register-expense-agent.sh
15
+
16
+ set -euo pipefail
17
+
18
+ CHANNEL_ID="${EXPENSE_CHANNEL_ID:?Set EXPENSE_CHANNEL_ID=C...}"
19
+ ENV_FILE="${ENV_FILE:-$HOME/nanoclaw-docker/.env}"
20
+ API="http://localhost:10254"
21
+
22
+ echo "=== Registering expense-policy-checker agent ==="
23
+ echo ""
24
+
25
+ cd ~/nanoclaw-docker
26
+
27
+ # 1. Register in NanoClaw database
28
+ echo "Registering agent in NanoClaw..."
29
+ npx tsx setup/index.ts register \
30
+ --jid "slack:${CHANNEL_ID}" \
31
+ --name "Expense Policy Checker" \
32
+ --trigger "@Argus" \
33
+ --folder "expense-policy-checker" \
34
+ --channel slack \
35
+ --no-trigger-required
36
+ echo " ✓ Agent registered"
37
+
38
+ # 2. Register Expensify secret in OneCLI
39
+ # Note: Expensify uses credentials in JSON request bodies, not HTTP headers.
40
+ # OneCLI cannot inject body params, so these are passed as env vars.
41
+ # We still register a marker secret so the agent identity is tracked in OneCLI.
42
+ echo ""
43
+ echo "Registering OneCLI agent for expense-policy-checker..."
44
+
45
+ RAND_TOKEN=$(openssl rand -hex 20)
46
+ EXISTING=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c \
47
+ "SELECT COUNT(*) FROM agents WHERE name='expense-policy-checker';" | tr -d ' \n')
48
+
49
+ if [ "$EXISTING" = "0" ]; then
50
+ docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
51
+ "INSERT INTO agents (id, name, access_token, identifier, project_id, created_at, updated_at)
52
+ VALUES ('nanoclaw-expense', 'expense-policy-checker', 'aoc_${RAND_TOKEN}',
53
+ 'expense-policy-checker', (SELECT id FROM projects LIMIT 1), NOW(), NOW())
54
+ ON CONFLICT (id) DO NOTHING;" > /dev/null
55
+ echo " ✓ OneCLI agent created"
56
+ else
57
+ echo " ✓ OneCLI agent already exists"
58
+ fi
59
+
60
+ # Link litellm (LLM gateway) to expense-policy-checker
61
+ docker exec onecli-postgres-1 psql -U onecli -d onecli -c "
62
+ INSERT INTO agent_secrets (agent_id, secret_id, created_at, updated_at)
63
+ SELECT 'nanoclaw-expense', id, NOW(), NOW()
64
+ FROM secrets WHERE name = 'litellm'
65
+ ON CONFLICT DO NOTHING;
66
+ " > /dev/null
67
+ echo " ✓ LiteLLM secret linked"
68
+
69
+ # 3. Update sender allowlist to allow Linn and Elisabeth
70
+ echo ""
71
+ echo "Updating sender allowlist..."
72
+ ALLOWLIST_FILE="$HOME/.config/nanoclaw/sender-allowlist.json"
73
+ if [ -f "$ALLOWLIST_FILE" ]; then
74
+ # Add the channel with no sender restrictions (Linn + Elisabeth are the only members)
75
+ CURRENT=$(cat "$ALLOWLIST_FILE")
76
+ CHANNEL_ENTRY="\"slack:${CHANNEL_ID}\": {\"mode\": \"allow\", \"senders\": []}"
77
+ if echo "$CURRENT" | grep -q "$CHANNEL_ID"; then
78
+ echo " ✓ Channel already in allowlist"
79
+ else
80
+ echo " ✓ Add this to ~/.config/nanoclaw/sender-allowlist.json manually:"
81
+ echo " \"slack:${CHANNEL_ID}\": {\"mode\": \"allow\", \"senders\": []}"
82
+ fi
83
+ else
84
+ echo " ⚠ No sender-allowlist.json found at $ALLOWLIST_FILE — create it manually"
85
+ fi
86
+
87
+ # 4. Set up scheduled tasks
88
+ echo ""
89
+ echo "Creating scheduled tasks..."
90
+
91
+ # Daily compliance check (9am Oslo time, Mon-Fri)
92
+ npx tsx setup/index.ts schedule-task \
93
+ --jid "slack:${CHANNEL_ID}" \
94
+ --prompt "Run the daily expense compliance check. Fetch reports for the last ${CHECK_LOOKBACK_DAYS:-7} days, inspect receipts, check policy, notify submitters and approvers of any violations, and update the state file." \
95
+ --cron "0 9 * * 1-5" \
96
+ --name "Daily expense compliance check" 2>/dev/null && \
97
+ echo " ✓ Daily check scheduled (9am Mon-Fri Oslo time)" || \
98
+ echo " ⚠ Could not schedule daily task — set it up via Global Claw or manually"
99
+
100
+ # Weekly report (Monday 8am)
101
+ npx tsx setup/index.ts schedule-task \
102
+ --jid "slack:${CHANNEL_ID}" \
103
+ --prompt "Generate and post the weekly expense compliance report to the finance Slack channel." \
104
+ --cron "0 8 * * 1" \
105
+ --name "Weekly expense report" 2>/dev/null && \
106
+ echo " ✓ Weekly report scheduled (8am Mondays)" || \
107
+ echo " ⚠ Could not schedule weekly task — set it up via Global Claw or manually"
108
+
109
+ echo ""
110
+ echo "=== Done! ==="
111
+ echo ""
112
+ echo "Next steps:"
113
+ echo " 1. Add Expensify credentials to .env on the VM:"
114
+ echo " EXPENSIFY_PARTNER_USER_ID=..."
115
+ echo " EXPENSIFY_PARTNER_USER_SECRET=..."
116
+ echo " EXPENSIFY_POLICY_ID=..."
117
+ echo " EXPENSE_CHANNEL_ID=${CHANNEL_ID}"
118
+ echo ""
119
+ echo " 2. Restart NanoClaw: (kill tsx and restart npm run dev)"
120
+ echo ""
121
+ echo " 3. Test by sending 'run expense check' in the channel"
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env tsx
2
+ import { execFileSync, execSync } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ function compareSemver(a: string, b: string): number {
7
+ const partsA = a.split('.').map(Number);
8
+ const partsB = b.split('.').map(Number);
9
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
10
+ const diff = (partsA[i] || 0) - (partsB[i] || 0);
11
+ if (diff !== 0) return diff;
12
+ }
13
+ return 0;
14
+ }
15
+
16
+ // Resolve tsx binary once to avoid npx race conditions across migrations
17
+ function resolveTsx(): string {
18
+ // Check local node_modules first
19
+ const local = path.resolve('node_modules/.bin/tsx');
20
+ if (fs.existsSync(local)) return local;
21
+ // Fall back to whichever tsx is in PATH
22
+ try {
23
+ return execSync('which tsx', { encoding: 'utf-8' }).trim();
24
+ } catch {
25
+ return 'npx'; // last resort
26
+ }
27
+ }
28
+
29
+ const tsxBin = resolveTsx();
30
+
31
+ const fromVersion = process.argv[2];
32
+ const toVersion = process.argv[3];
33
+ const newCorePath = process.argv[4];
34
+
35
+ if (!fromVersion || !toVersion || !newCorePath) {
36
+ console.error(
37
+ 'Usage: tsx scripts/run-migrations.ts <from-version> <to-version> <new-core-path>',
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ interface MigrationResult {
43
+ version: string;
44
+ success: boolean;
45
+ error?: string;
46
+ }
47
+
48
+ const results: MigrationResult[] = [];
49
+
50
+ // Look for migrations in the new core
51
+ const migrationsDir = path.join(newCorePath, 'migrations');
52
+
53
+ if (!fs.existsSync(migrationsDir)) {
54
+ console.log(JSON.stringify({ migrationsRun: 0, results: [] }, null, 2));
55
+ process.exit(0);
56
+ }
57
+
58
+ // Discover migration directories (version-named)
59
+ const entries = fs.readdirSync(migrationsDir, { withFileTypes: true });
60
+ const migrationVersions = entries
61
+ .filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
62
+ .map((e) => e.name)
63
+ .filter(
64
+ (v) =>
65
+ compareSemver(v, fromVersion) > 0 && compareSemver(v, toVersion) <= 0,
66
+ )
67
+ .sort(compareSemver);
68
+
69
+ const projectRoot = process.cwd();
70
+
71
+ for (const version of migrationVersions) {
72
+ const migrationIndex = path.join(migrationsDir, version, 'index.ts');
73
+ if (!fs.existsSync(migrationIndex)) {
74
+ results.push({
75
+ version,
76
+ success: false,
77
+ error: `Migration ${version}/index.ts not found`,
78
+ });
79
+ continue;
80
+ }
81
+
82
+ try {
83
+ const tsxArgs = tsxBin.endsWith('npx')
84
+ ? ['tsx', migrationIndex, projectRoot]
85
+ : [migrationIndex, projectRoot];
86
+ execFileSync(tsxBin, tsxArgs, {
87
+ stdio: 'pipe',
88
+ cwd: projectRoot,
89
+ timeout: 120_000,
90
+ });
91
+ results.push({ version, success: true });
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ results.push({ version, success: false, error: message });
95
+ }
96
+ }
97
+
98
+ console.log(
99
+ JSON.stringify({ migrationsRun: results.length, results }, null, 2),
100
+ );
101
+
102
+ // Exit with error if any migration failed
103
+ if (results.some((r) => !r.success)) {
104
+ process.exit(1);
105
+ }
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env bash
2
+ # setup-onecli-secrets.sh
3
+ #
4
+ # Configure OneCLI secrets via the LOCAL HTTP API (handles encryption correctly).
5
+ # Direct DB insertion bypasses encryption and breaks the gateway — never do that.
6
+ #
7
+ # Run this on the VM after deploying NanoClaw:
8
+ # cd ~/nanoclaw-docker && bash scripts/setup-onecli-secrets.sh
9
+
10
+ set -euo pipefail
11
+
12
+ ENV_FILE="${ENV_FILE:-$(dirname "$0")/../.env}"
13
+ API="http://localhost:10254"
14
+
15
+ # Read ONECLI_API_KEY from env or .env file
16
+ ONECLI_API_KEY="${ONECLI_API_KEY:-$(grep -E '^ONECLI_API_KEY=' "$ENV_FILE" 2>/dev/null | head -1 | sed 's/^[^=]*=//' | tr -d '"'"'" || true)}"
17
+
18
+ # Wrapper: curl with optional Bearer auth
19
+ api_curl() {
20
+ if [ -n "$ONECLI_API_KEY" ]; then
21
+ curl -s -H "Authorization: Bearer $ONECLI_API_KEY" "$@"
22
+ else
23
+ curl -s "$@"
24
+ fi
25
+ }
26
+
27
+ # ── Helpers ──────────────────────────────────────────────────────────────────
28
+
29
+ env_val() {
30
+ grep -E "^${1}=" "$ENV_FILE" | head -1 | sed 's/^[^=]*=//' | tr -d '"'"'"
31
+ }
32
+
33
+ # Create or update a secret via the API (handles AES encryption automatically).
34
+ # If a secret with the same name already exists, delete it first.
35
+ upsert_secret() {
36
+ local name="$1"
37
+ local host_pattern="$2"
38
+ local header_name="$3"
39
+ local value="$4"
40
+
41
+ # Delete existing secret with this name (if any)
42
+ local existing_id
43
+ existing_id=$(api_curl "$API/api/secrets" | python3 -c "
44
+
45
+ import json,sys
46
+ secrets = json.load(sys.stdin)
47
+ match = next((s for s in secrets if s['name'] == '${name}'), None)
48
+ print(match['id'] if match else '')
49
+ " 2>/dev/null || echo "")
50
+
51
+ if [ -n "$existing_id" ]; then
52
+ api_curl -X DELETE "$API/api/secrets/$existing_id" > /dev/null
53
+ fi
54
+
55
+ # Create via API — value is encrypted by the API before storing
56
+ local result
57
+ result=$(api_curl -X POST "$API/api/secrets" \
58
+ -H 'Content-Type: application/json' \
59
+ -d "$(python3 -c "
60
+ import json
61
+ print(json.dumps({
62
+ 'name': '${name}',
63
+ 'type': 'generic',
64
+ 'value': '''${value}''',
65
+ 'hostPattern': '${host_pattern}',
66
+ 'injectionConfig': {'headerName': '${header_name}'}
67
+ }))
68
+ ")")
69
+
70
+ if echo "$result" | grep -q '"error"'; then
71
+ echo " ✗ Failed '${name}': $result"
72
+ else
73
+ echo " ✓ Secret '${name}' → ${host_pattern} (${header_name})"
74
+ fi
75
+ }
76
+
77
+ link_secret_to_agent() {
78
+ local secret_name="$1"
79
+ local agent_name="$2"
80
+
81
+ # Match by identifier (set to folder name by setup-agents.sh) first,
82
+ # fall back to name for agents registered before identifier was used.
83
+ docker exec onecli-postgres-1 psql -U onecli -d onecli -c "
84
+ INSERT INTO agent_secrets (agent_id, secret_id, created_at, updated_at)
85
+ SELECT a.id, s.id, NOW(), NOW()
86
+ FROM agents a, secrets s
87
+ WHERE (a.identifier = '${agent_name}' OR (COALESCE(a.identifier, '') = '' AND a.name = '${agent_name}'))
88
+ AND s.name = '${secret_name}'
89
+ ON CONFLICT DO NOTHING;
90
+ " > /dev/null 2>&1
91
+
92
+ local linked
93
+ linked=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c "
94
+ SELECT COUNT(*) FROM agent_secrets ags
95
+ JOIN agents a ON a.id = ags.agent_id
96
+ JOIN secrets s ON s.id = ags.secret_id
97
+ WHERE (a.identifier = '${agent_name}' OR (COALESCE(a.identifier, '') = '' AND a.name = '${agent_name}'))
98
+ AND s.name = '${secret_name}';
99
+ " | tr -d ' \n')
100
+
101
+ if [ "$linked" = "1" ]; then
102
+ echo " ✓ Linked '${secret_name}' → '${agent_name}'"
103
+ else
104
+ echo " ⚠ Could not link '${secret_name}' to '${agent_name}'"
105
+ fi
106
+ }
107
+
108
+ # ── Main ─────────────────────────────────────────────────────────────────────
109
+
110
+ if [ ! -f "$ENV_FILE" ]; then
111
+ echo "ERROR: .env not found at $ENV_FILE"
112
+ exit 1
113
+ fi
114
+
115
+ echo "Reading credentials from $ENV_FILE"
116
+ echo ""
117
+
118
+ # Read all credentials
119
+ ELASTIC_API_KEY=$(env_val "ELASTIC_API_KEY")
120
+ ELASTIC_BASE_URL=$(env_val "ELASTIC_BASE_URL")
121
+ ELASTIC_HOST=$(echo "$ELASTIC_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
122
+
123
+ JIRA_EMAIL=$(env_val "JIRA_EMAIL")
124
+ JIRA_API_TOKEN=$(env_val "JIRA_API_TOKEN")
125
+ JIRA_BASE_URL=$(env_val "JIRA_BASE_URL")
126
+ JIRA_HOST=$(echo "$JIRA_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
127
+ JIRA_BASIC=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64 | tr -d '\n')
128
+
129
+ CONFLUENCE_USERNAME=$(env_val "CONFLUENCE_USERNAME")
130
+ CONFLUENCE_PASSWORD=$(env_val "CONFLUENCE_PASSWORD")
131
+ CONFLUENCE_BASE_URL=$(env_val "CONFLUENCE_BASE_URL")
132
+ CONFLUENCE_HOST=$(echo "$CONFLUENCE_BASE_URL" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
133
+ CONFLUENCE_BASIC=$(printf '%s:%s' "$CONFLUENCE_USERNAME" "$CONFLUENCE_PASSWORD" | base64 | tr -d '\n')
134
+
135
+ SLACK_BOT_TOKEN=$(env_val "SLACK_BOT_TOKEN")
136
+ INTERCOM_ACCESS_TOKEN=$(env_val "INTERCOM_ACCESS_TOKEN")
137
+
138
+
139
+ ANTHROPIC_AUTH_TOKEN=$(env_val "ANTHROPIC_AUTH_TOKEN")
140
+ LLM_GATEWAY=$(env_val "ANTHROPIC_BASE_URL")
141
+ LLM_GATEWAY_HOST=$(echo "$LLM_GATEWAY" | sed 's|^https://||;s|^http://||' | cut -d/ -f1)
142
+
143
+ # ── Register secrets via API (with proper encryption) ─────────────────────────
144
+
145
+ echo "Registering secrets..."
146
+ echo ""
147
+
148
+ [ -n "$ELASTIC_API_KEY" ] && [ -n "$ELASTIC_HOST" ] && \
149
+ upsert_secret "elastic" "$ELASTIC_HOST" "Authorization" "ApiKey $ELASTIC_API_KEY" || \
150
+ echo " ⚠ Skipping elastic"
151
+
152
+ [ -n "$JIRA_BASIC" ] && [ -n "$JIRA_HOST" ] && \
153
+ upsert_secret "jira" "$JIRA_HOST" "Authorization" "Basic $JIRA_BASIC" || \
154
+ echo " ⚠ Skipping jira"
155
+
156
+ # Confluence: only if host differs from Jira (usually same Atlassian instance)
157
+ if [ -n "$CONFLUENCE_BASIC" ] && [ -n "$CONFLUENCE_HOST" ] && [ "$CONFLUENCE_HOST" != "$JIRA_HOST" ]; then
158
+ upsert_secret "confluence" "$CONFLUENCE_HOST" "Authorization" "Basic $CONFLUENCE_BASIC"
159
+ else
160
+ echo " ✓ Confluence shares host with Jira — using jira secret for both"
161
+ fi
162
+
163
+ [ -n "$SLACK_BOT_TOKEN" ] && \
164
+ upsert_secret "slack" "slack.com" "Authorization" "Bearer $SLACK_BOT_TOKEN" || \
165
+ echo " ⚠ Skipping slack"
166
+
167
+ [ -n "$INTERCOM_ACCESS_TOKEN" ] && \
168
+ upsert_secret "intercom" "api.intercom.io" "Authorization" "Bearer $INTERCOM_ACCESS_TOKEN" || \
169
+ echo " ⚠ Skipping intercom"
170
+
171
+
172
+ [ -n "$ANTHROPIC_AUTH_TOKEN" ] && [ -n "$LLM_GATEWAY_HOST" ] && \
173
+ upsert_secret "litellm" "$LLM_GATEWAY_HOST" "Authorization" "Bearer $ANTHROPIC_AUTH_TOKEN" || \
174
+ echo " ⚠ Skipping litellm"
175
+
176
+ echo ""
177
+
178
+ # ── Register global-claw agent if missing ────────────────────────────────────
179
+
180
+ echo "Ensuring agents exist..."
181
+ GLOBAL_CLAW_EXISTS=$(docker exec onecli-postgres-1 psql -U onecli -d onecli -t -c \
182
+ "SELECT COUNT(*) FROM agents WHERE name='global-claw';" | tr -d ' \n')
183
+
184
+ if [ "$GLOBAL_CLAW_EXISTS" = "0" ]; then
185
+ RAND_TOKEN=$(openssl rand -hex 20)
186
+ # Try new schema (account_id) first, fall back to old schema (project_id)
187
+ docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
188
+ "INSERT INTO agents (id, name, access_token, identifier, secret_mode, account_id, created_at, updated_at)
189
+ SELECT 'nanoclaw-global-claw', 'global-claw', 'aoc_${RAND_TOKEN}', 'global-claw', 'selective', id, NOW(), NOW()
190
+ FROM accounts LIMIT 1
191
+ ON CONFLICT (id) DO NOTHING;" > /dev/null 2>&1 || \
192
+ docker exec onecli-postgres-1 psql -U onecli -d onecli -c \
193
+ "INSERT INTO agents (id, name, access_token, identifier, secret_mode, project_id, created_at, updated_at)
194
+ VALUES ('nanoclaw-global-claw', 'global-claw', 'aoc_${RAND_TOKEN}', 'global-claw', 'selective',
195
+ (SELECT id FROM projects LIMIT 1), NOW(), NOW())
196
+ ON CONFLICT (id) DO NOTHING;" > /dev/null 2>&1
197
+ echo " ✓ Created global-claw agent"
198
+ else
199
+ echo " ✓ global-claw agent already exists"
200
+ fi
201
+ echo ""
202
+
203
+ # ── Link secrets to agents (driven by agents.yaml — single source of truth) ───
204
+ #
205
+ # Reads onecli_secrets from agents.yaml for each agent.
206
+ # Adding a new agent with onecli_secrets there is all that's needed —
207
+ # no changes to this script required.
208
+
209
+ AGENTS_YAML="${AGENTS_YAML:-$(dirname "$0")/../agents.yaml}"
210
+
211
+ if [ ! -f "$AGENTS_YAML" ]; then
212
+ echo "ERROR: agents.yaml not found at $AGENTS_YAML"
213
+ exit 1
214
+ fi
215
+
216
+ # Ensure PyYAML is available
217
+ if ! python3 -c "import yaml" 2>/dev/null; then
218
+ echo "Installing PyYAML (required for agents.yaml parsing)..."
219
+ pip3 install pyyaml --quiet --break-system-packages 2>/dev/null || \
220
+ pip3 install pyyaml --quiet 2>/dev/null || \
221
+ pip install pyyaml --quiet 2>/dev/null || \
222
+ { echo "ERROR: Could not install PyYAML. Run: pip3 install pyyaml"; exit 1; }
223
+ fi
224
+
225
+ echo "Linking secrets to agents (from agents.yaml)..."
226
+ echo ""
227
+
228
+ # Parse agents.yaml → emit "secret_name agent_folder" lines
229
+ LINKS=$(AGENTS_YAML_PATH="$AGENTS_YAML" python3 - << 'PYEOF'
230
+ import yaml, os
231
+
232
+ with open(os.environ["AGENTS_YAML_PATH"]) as f:
233
+ config = yaml.safe_load(f)
234
+
235
+ for agent in config.get("agents", []):
236
+ folder = agent.get("folder", "")
237
+ secrets = agent.get("onecli_secrets", [])
238
+ for secret in secrets:
239
+ print(f"{secret} {folder}")
240
+ PYEOF
241
+ )
242
+
243
+ if [ -z "$LINKS" ]; then
244
+ echo " ⚠ No agent-secret links found in agents.yaml"
245
+ else
246
+ while IFS=' ' read -r secret agent; do
247
+ [ -n "$secret" ] && [ -n "$agent" ] && link_secret_to_agent "$secret" "$agent"
248
+ done <<< "$LINKS"
249
+ fi
250
+
251
+ echo ""
252
+ echo "Done. Verify with: curl http://localhost:10254/api/secrets | python3 -m json.tool | grep name"