@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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
+ }