@cocorograph/hub-agent 0.4.1 → 0.5.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/bin/hub-agent.mjs +34 -1
- package/package.json +1 -1
- package/src/enroll.mjs +21 -0
- package/src/hub-bundle.mjs +242 -0
package/bin/hub-agent.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { Command } from "commander"
|
|
|
19
19
|
|
|
20
20
|
import { hasConfig, readConfig } from "../src/config.mjs"
|
|
21
21
|
import { enroll } from "../src/enroll.mjs"
|
|
22
|
+
import { syncBundle } from "../src/hub-bundle.mjs"
|
|
22
23
|
import {
|
|
23
24
|
installPlugin,
|
|
24
25
|
listInstallableExamples,
|
|
@@ -38,10 +39,11 @@ program
|
|
|
38
39
|
|
|
39
40
|
program
|
|
40
41
|
.command("enroll <enrollment_token>")
|
|
41
|
-
.description("Hub から発行された enrollment token で agent を登録する")
|
|
42
|
+
.description("Hub から発行された enrollment token で agent を登録する + Hub AI bundle 同期")
|
|
42
43
|
.option("--hub-url <url>", "Hub の base URL (default: $HUB_URL or https://hub.cocorograph.com)")
|
|
43
44
|
.option("--hostname <name>", "Hub に表示するホスト名 (default: os.hostname())")
|
|
44
45
|
.option("--force", "既存設定を上書きする")
|
|
46
|
+
.option("--no-bundle", "Hub AI bundle 同期 (~/.claude/ への展開) をスキップ")
|
|
45
47
|
.action(async (token, opts) => {
|
|
46
48
|
try {
|
|
47
49
|
const r = await enroll(token, {
|
|
@@ -49,14 +51,45 @@ program
|
|
|
49
51
|
hostname: opts.hostname,
|
|
50
52
|
version: pkg.version,
|
|
51
53
|
force: !!opts.force,
|
|
54
|
+
skipBundle: opts.bundle === false,
|
|
52
55
|
})
|
|
53
56
|
console.log(`enroll succeeded: agent_id=${r.agent_id} primary=${r.is_primary} hub=${r.hub_url}`)
|
|
57
|
+
if (r.bundle?.error) {
|
|
58
|
+
console.error(`hub bundle sync warning: ${r.bundle.error}`)
|
|
59
|
+
} else if (r.bundle) {
|
|
60
|
+
console.log(
|
|
61
|
+
`hub bundle synced: version=${r.bundle.version} written=${r.bundle.written?.length ?? 0} ` +
|
|
62
|
+
`skipped_same=${r.bundle.skipped_same?.length ?? 0}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
54
65
|
} catch (err) {
|
|
55
66
|
console.error(`enroll failed: ${err.message}`)
|
|
56
67
|
process.exit(1)
|
|
57
68
|
}
|
|
58
69
|
})
|
|
59
70
|
|
|
71
|
+
program
|
|
72
|
+
.command("sync-bundle")
|
|
73
|
+
.description("Hub AI bundle を手動で再同期 (CLAUDE.md / scripts / hooks / projects.json を ~/.claude/ に展開)")
|
|
74
|
+
.action(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const cfg = await readConfig()
|
|
77
|
+
if (!cfg) {
|
|
78
|
+
console.error("not enrolled (run `hub-agent enroll <token>` first)")
|
|
79
|
+
process.exit(2)
|
|
80
|
+
}
|
|
81
|
+
const r = await syncBundle({
|
|
82
|
+
hubUrl: cfg.hub_url,
|
|
83
|
+
agentId: cfg.agent_id,
|
|
84
|
+
agentToken: cfg.agent_token,
|
|
85
|
+
})
|
|
86
|
+
console.log(`hub bundle synced: version=${r.version} written=${r.written.length} skipped_same=${r.skipped_same.length}`)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(`sync-bundle failed: ${err.message}`)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
60
93
|
program
|
|
61
94
|
.command("start")
|
|
62
95
|
.description("daemon を起動して Hub と WSS 接続する")
|
package/package.json
CHANGED
package/src/enroll.mjs
CHANGED
|
@@ -59,10 +59,31 @@ export async function enroll(enrollmentToken, opts = {}) {
|
|
|
59
59
|
hub_url: hubUrl,
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
+
// Hub AI bundle を続けて同期する (CLAUDE.md / scripts / hooks / projects.json
|
|
63
|
+
// を ~/.claude/ に展開)。失敗しても enroll 自体は成功扱いにする (--no-bundle
|
|
64
|
+
// で skip 可能)。
|
|
65
|
+
let bundleResult = null
|
|
66
|
+
if (!opts.skipBundle) {
|
|
67
|
+
try {
|
|
68
|
+
const { syncBundle } = await import("./hub-bundle.mjs")
|
|
69
|
+
bundleResult = await syncBundle({
|
|
70
|
+
hubUrl,
|
|
71
|
+
agentId: data.agent_id,
|
|
72
|
+
agentToken: data.agent_token,
|
|
73
|
+
logger: opts.logger,
|
|
74
|
+
fetchImpl,
|
|
75
|
+
})
|
|
76
|
+
} catch (err) {
|
|
77
|
+
opts.logger?.warn?.({ err: err.message }, "hub bundle sync failed (enroll still ok)")
|
|
78
|
+
bundleResult = { error: err.message }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
return {
|
|
63
83
|
agent_id: data.agent_id,
|
|
64
84
|
is_primary: !!data.is_primary,
|
|
65
85
|
hub_url: hubUrl,
|
|
86
|
+
bundle: bundleResult,
|
|
66
87
|
}
|
|
67
88
|
}
|
|
68
89
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub AI bundle 同期 (Sprint M)。
|
|
3
|
+
*
|
|
4
|
+
* Hub backend の `GET /api/knowledge/admin/hub-ai/setup-bundle/` を agent token
|
|
5
|
+
* 認証で叩き、レスポンスの files[] を `~/.claude/<path>` に配置する。
|
|
6
|
+
* MCP `setup_hub_ai_environment` ツールがやっているのと同じことを CLI 経由で
|
|
7
|
+
* 実行する形。
|
|
8
|
+
*
|
|
9
|
+
* 既存ユーザーで再実行しても idempotent:
|
|
10
|
+
* - CLAUDE.md は `<!-- BEGIN HUB-AI-RULES -->` 〜 `<!-- END HUB-AI-RULES -->`
|
|
11
|
+
* マーカー間のみ置換 (Hub backend 側の `_build_files` 経由で content が
|
|
12
|
+
* そのまま入っているので、CLAUDE.md の場合は marker 範囲だけ replace する)
|
|
13
|
+
* - 他のファイルは内容差分がある時のみ書き換え (mtime 比較ではなく hash 比較)
|
|
14
|
+
* - post_install_commands は冪等なシェルコマンドを Hub manifest 側で記述する
|
|
15
|
+
* 想定
|
|
16
|
+
*
|
|
17
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from "node:fs"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
import os from "node:os"
|
|
22
|
+
import { spawn } from "node:child_process"
|
|
23
|
+
|
|
24
|
+
const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
|
|
25
|
+
const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
|
|
26
|
+
|
|
27
|
+
function resolveClaudeDir() {
|
|
28
|
+
return process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tokenPath() {
|
|
32
|
+
return path.join(resolveClaudeDir(), "scripts", ".hub_jwt.json")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bundleUrl(hubUrl) {
|
|
36
|
+
return `${hubUrl.replace(/\/+$/, "")}/api/knowledge/admin/hub-ai/setup-bundle/`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hub から bundle を fetch する。
|
|
41
|
+
* @returns {Promise<{ version: string, files: any[], post_install_commands?: any[], token?: any }>}
|
|
42
|
+
*/
|
|
43
|
+
export async function fetchBundle({ hubUrl, agentId, agentToken, fetchImpl } = {}) {
|
|
44
|
+
if (!hubUrl) throw new Error("fetchBundle requires hubUrl")
|
|
45
|
+
if (!agentId || !agentToken) throw new Error("fetchBundle requires agentId + agentToken")
|
|
46
|
+
const f = fetchImpl || globalThis.fetch
|
|
47
|
+
const res = await f(bundleUrl(hubUrl), {
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${agentId}:${agentToken}`,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
let detail = `HTTP ${res.status}`
|
|
54
|
+
try {
|
|
55
|
+
const j = await res.json()
|
|
56
|
+
if (j?.detail) detail = j.detail
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`hub bundle fetch failed: ${detail}`)
|
|
61
|
+
}
|
|
62
|
+
return res.json()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isClaudeMdEntry(entry) {
|
|
66
|
+
return entry.path === "CLAUDE.md" || entry.path === "./CLAUDE.md"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function intoClaudeAbsolute(rel) {
|
|
70
|
+
const base = resolveClaudeDir()
|
|
71
|
+
const cleaned = rel.replace(/^\.\//, "")
|
|
72
|
+
return path.join(base, cleaned)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseMode(mode) {
|
|
76
|
+
if (typeof mode === "number") return mode
|
|
77
|
+
if (typeof mode === "string") {
|
|
78
|
+
const v = parseInt(mode, 8)
|
|
79
|
+
if (Number.isFinite(v)) return v
|
|
80
|
+
}
|
|
81
|
+
return 0o644
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bundle の files[] を `~/.claude/<path>` に展開する。
|
|
86
|
+
* @returns {Promise<{ written: string[], skipped_same: string[], merged_claude_md: boolean }>}
|
|
87
|
+
*/
|
|
88
|
+
export async function applyBundle({ bundle, logger } = {}) {
|
|
89
|
+
if (!bundle || !Array.isArray(bundle.files)) {
|
|
90
|
+
throw new Error("applyBundle requires bundle.files[]")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const written = []
|
|
94
|
+
const skippedSame = []
|
|
95
|
+
let mergedClaudeMd = false
|
|
96
|
+
|
|
97
|
+
for (const entry of bundle.files) {
|
|
98
|
+
const dest = intoClaudeAbsolute(entry.path)
|
|
99
|
+
const mode = parseMode(entry.mode)
|
|
100
|
+
const content = entry.content ?? ""
|
|
101
|
+
|
|
102
|
+
if (isClaudeMdEntry(entry)) {
|
|
103
|
+
// CLAUDE.md は marker 範囲だけ置換、無ければ末尾追記
|
|
104
|
+
const result = await mergeClaudeMdMarkerBlock(dest, content, { logger })
|
|
105
|
+
if (result.changed) {
|
|
106
|
+
written.push(dest)
|
|
107
|
+
mergedClaudeMd = true
|
|
108
|
+
} else {
|
|
109
|
+
skippedSame.push(dest)
|
|
110
|
+
}
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 通常ファイル: 既存と完全一致なら skip、差分あれば書き換え
|
|
115
|
+
const existing = await readOrNull(dest)
|
|
116
|
+
if (existing !== null && existing === content) {
|
|
117
|
+
skippedSame.push(dest)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
121
|
+
await fs.writeFile(dest, content, { mode })
|
|
122
|
+
written.push(dest)
|
|
123
|
+
logger?.info({ dest, mode: mode.toString(8) }, "hub-bundle wrote file")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// JWT を保存 (~/.claude/scripts/.hub_jwt.json)
|
|
127
|
+
if (bundle.token) {
|
|
128
|
+
const tp = tokenPath()
|
|
129
|
+
await fs.mkdir(path.dirname(tp), { recursive: true })
|
|
130
|
+
await fs.writeFile(
|
|
131
|
+
tp,
|
|
132
|
+
JSON.stringify({ ...bundle.token, version: bundle.version }, null, 2),
|
|
133
|
+
{ mode: 0o600 },
|
|
134
|
+
)
|
|
135
|
+
written.push(tp)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { written, skipped_same: skippedSame, merged_claude_md: mergedClaudeMd }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readOrNull(p) {
|
|
142
|
+
try {
|
|
143
|
+
return await fs.readFile(p, "utf-8")
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err.code === "ENOENT") return null
|
|
146
|
+
throw err
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* CLAUDE.md のマーカー範囲を置換する。マーカーが無ければ末尾に追記。
|
|
152
|
+
* incoming content には marker 込みのテキストが入っている想定 (Hub 側 fragment)。
|
|
153
|
+
*/
|
|
154
|
+
export async function mergeClaudeMdMarkerBlock(dest, incoming, { logger } = {}) {
|
|
155
|
+
const existing = await readOrNull(dest)
|
|
156
|
+
// incoming は marker line を含む全文 (BEGIN ... END)、それ以外 (前後文) は剥がす
|
|
157
|
+
const incomingBlock = extractMarkerBlock(incoming) || incoming
|
|
158
|
+
|
|
159
|
+
if (existing === null) {
|
|
160
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
161
|
+
await fs.writeFile(dest, incomingBlock + "\n", { mode: 0o644 })
|
|
162
|
+
logger?.info({ dest }, "hub-bundle created CLAUDE.md")
|
|
163
|
+
return { changed: true, created: true }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const beginIdx = existing.indexOf(HUB_AI_RULES_BEGIN)
|
|
167
|
+
const endIdx = existing.indexOf(HUB_AI_RULES_END)
|
|
168
|
+
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
169
|
+
// marker が無い → 末尾に追記
|
|
170
|
+
if (existing.includes(incomingBlock)) {
|
|
171
|
+
return { changed: false }
|
|
172
|
+
}
|
|
173
|
+
const next = existing.endsWith("\n") ? `${existing}\n${incomingBlock}\n` : `${existing}\n\n${incomingBlock}\n`
|
|
174
|
+
await fs.writeFile(dest, next, { mode: 0o644 })
|
|
175
|
+
logger?.info({ dest }, "hub-bundle appended hub-ai marker block")
|
|
176
|
+
return { changed: true, appended: true }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const before = existing.slice(0, beginIdx)
|
|
180
|
+
const afterStart = endIdx + HUB_AI_RULES_END.length
|
|
181
|
+
const after = existing.slice(afterStart)
|
|
182
|
+
const next = `${before}${incomingBlock}${after}`
|
|
183
|
+
if (next === existing) {
|
|
184
|
+
return { changed: false }
|
|
185
|
+
}
|
|
186
|
+
await fs.writeFile(dest, next, { mode: 0o644 })
|
|
187
|
+
logger?.info({ dest }, "hub-bundle replaced hub-ai marker block")
|
|
188
|
+
return { changed: true }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractMarkerBlock(text) {
|
|
192
|
+
const a = text.indexOf(HUB_AI_RULES_BEGIN)
|
|
193
|
+
const b = text.indexOf(HUB_AI_RULES_END)
|
|
194
|
+
if (a === -1 || b === -1 || b < a) return null
|
|
195
|
+
return text.slice(a, b + HUB_AI_RULES_END.length)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* post_install_commands を順次実行する。manifest に list で書く想定。
|
|
200
|
+
* Hub backend が冪等なコマンドを記述する責任を持つ (例: chmod +x ...)。
|
|
201
|
+
*/
|
|
202
|
+
export async function runPostInstall(bundle, { logger } = {}) {
|
|
203
|
+
const cmds = Array.isArray(bundle.post_install_commands)
|
|
204
|
+
? bundle.post_install_commands
|
|
205
|
+
: []
|
|
206
|
+
for (const cmd of cmds) {
|
|
207
|
+
const argv = typeof cmd === "string" ? ["/bin/sh", "-c", cmd] : cmd
|
|
208
|
+
if (!Array.isArray(argv) || argv.length === 0) continue
|
|
209
|
+
await runOnce(argv, { logger })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function runOnce(argv, { logger } = {}) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const [bin, ...args] = argv
|
|
216
|
+
const child = spawn(bin, args, { stdio: "inherit" })
|
|
217
|
+
child.on("exit", (code) => {
|
|
218
|
+
if (code === 0) {
|
|
219
|
+
logger?.info({ cmd: argv.join(" ") }, "hub-bundle post_install ok")
|
|
220
|
+
resolve(code)
|
|
221
|
+
} else {
|
|
222
|
+
const err = new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`)
|
|
223
|
+
logger?.warn({ cmd: argv.join(" "), code }, "hub-bundle post_install failed")
|
|
224
|
+
reject(err)
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* fetch + apply + post_install をまとめて実行。
|
|
232
|
+
*/
|
|
233
|
+
export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl } = {}) {
|
|
234
|
+
const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl })
|
|
235
|
+
const result = await applyBundle({ bundle, logger })
|
|
236
|
+
try {
|
|
237
|
+
await runPostInstall(bundle, { logger })
|
|
238
|
+
} catch (err) {
|
|
239
|
+
logger?.warn({ err: err.message }, "post_install partially failed (continuing)")
|
|
240
|
+
}
|
|
241
|
+
return { version: bundle.version, ...result }
|
|
242
|
+
}
|