@hiveku-apps/sync 0.1.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/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @hiveku-apps/sync
2
+
3
+ Local sync CLI for a Hiveku account. Pairs with the Hiveku MCP server at
4
+ `https://core.hiveku.com/mcp` to:
5
+
6
+ 1. **`hiveku-sync init <slug>`** — scaffold a per-account folder with `.env`,
7
+ `.mcp.json`, `CLAUDE.md`, and the empty `memory/ rules/ skills/` tree.
8
+ 2. **`hiveku-sync pull`** — fetch the account's memory/rules/skills via the
9
+ `memory_list` MCP tool and write each entry as a Markdown file with
10
+ YAML frontmatter (name, type, domain, department, version, updated_at).
11
+
12
+ The goal: open one folder per Hiveku tenant in VS Code with Claude Code,
13
+ and the local `.md` files become the source-of-truth Claude reads. Tools
14
+ are dispatched against the right tenant automatically via the API key
15
+ in `.env`.
16
+
17
+ ## Install
18
+
19
+ **Option A — npx (no install, recommended for trying it out):**
20
+
21
+ ```bash
22
+ npx @hiveku-apps/sync init <account-slug>
23
+ ```
24
+
25
+ **Option B — global install (faster for repeat use):**
26
+
27
+ ```bash
28
+ npm install -g @hiveku-apps/sync
29
+ hiveku-sync init <account-slug>
30
+ ```
31
+
32
+ **Option C — from source (local dev):**
33
+
34
+ ```bash
35
+ cd hiveku-sync
36
+ npm link # adds `hiveku-sync` to your PATH
37
+ ```
38
+
39
+ Requires Node 20+.
40
+
41
+ ## Publishing (maintainers)
42
+
43
+ ```bash
44
+ cd hiveku-sync
45
+ # Bump version in package.json
46
+ npm publish --access public
47
+ ```
48
+
49
+ Once published, the UI's "CLI connector" tab in `/dashboard/settings`
50
+ will show the `npx @hiveku-apps/sync init` command automatically.
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # Pick a parent folder for all your account workspaces
56
+ mkdir -p ~/Documents/hiveku-accounts && cd ~/Documents/hiveku-accounts
57
+
58
+ # Scaffold one account at a time
59
+ hiveku-sync init acme-corp --profile=sales
60
+ hiveku-sync init beta-inc --profile=marketing
61
+
62
+ # Paste credentials
63
+ cp acme-corp/.env.example acme-corp/.env
64
+ # Edit acme-corp/.env — set OLYMPUS_API_KEY to a Hiveku MCP key minted
65
+ # from https://app.hiveku.com/<account_id>/dashboard/settings
66
+
67
+ # Pull memory/rules/skills into local .md files
68
+ cd acme-corp && hiveku-sync pull
69
+ ```
70
+
71
+ Open `~/Documents/hiveku-accounts/hiveku.code-workspace` in VS Code —
72
+ all account folders are listed there.
73
+
74
+ ## Profiles
75
+
76
+ The `.mcp.json` URL embeds `?profile=<name>` so the MCP server returns a
77
+ narrowed tool list. Available: `full`, `sales`, `marketing`, `helpdesk`,
78
+ `pm`, `dev`. Change `HIVEKU_PROFILE` in `.env` and reload the MCP server
79
+ in your editor to switch.
80
+
81
+ ## Safety
82
+
83
+ - **Manifest pin** — `.hiveku/manifest.json` records the API key preview.
84
+ `pull` refuses if `.env` is later swapped to a key from a different
85
+ account. Delete the manifest to intentionally re-bind.
86
+ - **Atomic writes** — every file is written to `*.tmp` and renamed, so
87
+ a Ctrl-C mid-sync never leaves partial files.
88
+ - **No deletes in v1** — entries removed in Olympus are *not* deleted
89
+ from disk; they're listed under `removed_remotely_since_last_sync` in
90
+ the manifest. Prune manually if you want.
91
+
92
+ ## How writes work
93
+
94
+ `hiveku-sync` is pull-only. To persist changes back to Olympus, call the
95
+ `memory_create` / `memory_update` / `memory_delete` MCP tools through
96
+ Claude (or whichever LLM you've wired up). Then `hiveku-sync pull` to
97
+ refresh the local copies.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hiveku-sync — Hiveku account folder sync CLI.
4
+ *
5
+ * Usage:
6
+ * hiveku-sync init <slug> [--profile=<name>] Scaffold a per-account folder
7
+ * hiveku-sync pull [--profile=<name>] Pull memory/rules/skills locally
8
+ * hiveku-sync help
9
+ */
10
+
11
+ import { runInit } from '../src/init.mjs';
12
+ import { runPull } from '../src/pull.mjs';
13
+ import {
14
+ runProjectsList,
15
+ runPullProject,
16
+ runProjectStatus,
17
+ runProjectPush,
18
+ } from '../src/projects.mjs';
19
+
20
+ function parseFlags(argv) {
21
+ const positional = [];
22
+ const flags = {};
23
+ // Short flags that take a value (next arg is the value, not a new flag).
24
+ const VALUE_SHORT = new Set(['m']);
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const arg = argv[i];
27
+ if (arg.startsWith('--')) {
28
+ const eq = arg.indexOf('=');
29
+ if (eq === -1) {
30
+ flags[arg.slice(2)] = true;
31
+ } else {
32
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
33
+ }
34
+ } else if (arg.startsWith('-') && arg.length > 1) {
35
+ const key = arg.slice(1);
36
+ if (VALUE_SHORT.has(key) && i + 1 < argv.length) {
37
+ flags[key] = argv[i + 1];
38
+ i += 1;
39
+ } else {
40
+ flags[key] = true;
41
+ }
42
+ } else {
43
+ positional.push(arg);
44
+ }
45
+ }
46
+ return { positional, flags };
47
+ }
48
+
49
+ const HELP = `hiveku-sync — sync a Hiveku account into a local Claude Code folder
50
+
51
+ Memory/rules/skills:
52
+ init <slug> [--profile=<name>] Scaffold ./<slug>/ with .env, .mcp.json,
53
+ CLAUDE.md, and the memory/rules/skills tree.
54
+ pull [--profile=<name>] Pull memory/rules/skills via MCP into the
55
+ current account folder. Reads OLYMPUS_API_KEY
56
+ and OLYMPUS_BASE_URL from ./.env.
57
+
58
+ Coder projects:
59
+ projects List the account's coder projects.
60
+ pull-project <slug-or-id> Pull a project's source into ./projects/<slug>/.
61
+ [--branch=<name>] Records the project mapping in .hiveku/projects.json.
62
+ status [<slug>] [--json] Diff local ./projects/<slug>/ vs Hiveku DB.
63
+ cd into the project folder to omit <slug>.
64
+ push [<slug>] -m "<message>" Save+commit changed files to the project's
65
+ [--branch=<name>] GitHub branch via MCP (same path as the v3
66
+ [--include-deletes] sidebar). Add --include-deletes to commit
67
+ [--dry-run] paths missing locally as deletions.
68
+
69
+ help Show this message.
70
+
71
+ Profiles: full, sales, marketing, helpdesk, pm, dev
72
+ `;
73
+
74
+ async function main() {
75
+ const [cmd, ...rest] = process.argv.slice(2);
76
+ const { positional, flags } = parseFlags(rest);
77
+
78
+ switch (cmd) {
79
+ case 'init': {
80
+ const slug = positional[0];
81
+ if (!slug) {
82
+ console.error('Error: slug required.\n');
83
+ console.error(HELP);
84
+ process.exit(1);
85
+ }
86
+ const result = await runInit({
87
+ slug,
88
+ parentDir: flags['parent'],
89
+ profile: flags['profile'] || 'full',
90
+ });
91
+ console.log(`Initialized ${result.folder}`);
92
+ console.log(` profile: ${result.profile}`);
93
+ console.log(
94
+ ` next: cp ${result.folder}/.env.example ${result.folder}/.env and paste your OLYMPUS_API_KEY`,
95
+ );
96
+ if (result.workspaceAdded) {
97
+ console.log(` workspace: added entry to hiveku.code-workspace`);
98
+ }
99
+ console.log(` then: cd ${result.folder} && hiveku-sync pull`);
100
+ break;
101
+ }
102
+ case 'pull': {
103
+ const summary = await runPull({ profile: flags['profile'] });
104
+ console.log(`Pulled ${summary.total} entries (profile=${summary.profile}, base=${summary.baseUrl}):`);
105
+ for (const [type, count] of Object.entries(summary.by_type)) {
106
+ console.log(` ${type}: ${count}`);
107
+ }
108
+ if (summary.removed_remotely.length > 0) {
109
+ console.log('\nNote: these files were present in the last sync but are no longer in Olympus:');
110
+ for (const f of summary.removed_remotely) console.log(` ${f}`);
111
+ console.log('They were left on disk. Delete them manually if no longer needed.');
112
+ }
113
+ if (summary.errors.length > 0) {
114
+ console.error('\nErrors:');
115
+ for (const e of summary.errors) console.error(` ${e.type}: ${e.message}`);
116
+ process.exit(2);
117
+ }
118
+ break;
119
+ }
120
+ case 'projects': {
121
+ await runProjectsList({ profile: flags['profile'] });
122
+ break;
123
+ }
124
+ case 'pull-project': {
125
+ const slugOrId = positional[0];
126
+ if (!slugOrId) {
127
+ console.error('Error: pull-project requires a project slug or UUID.\n');
128
+ console.error(HELP);
129
+ process.exit(1);
130
+ }
131
+ await runPullProject({
132
+ slugOrId,
133
+ profile: flags['profile'],
134
+ branch: flags['branch'],
135
+ });
136
+ break;
137
+ }
138
+ case 'status': {
139
+ await runProjectStatus({
140
+ slug: positional[0],
141
+ profile: flags['profile'],
142
+ json: !!flags['json'],
143
+ });
144
+ break;
145
+ }
146
+ case 'push': {
147
+ // Positional layout: `push [<slug>]`. -m / --message is required.
148
+ const slug = positional[0];
149
+ const message = flags['m'] || flags['message'];
150
+ if (!message) {
151
+ console.error('Error: push requires -m "<commit message>"\n');
152
+ console.error(HELP);
153
+ process.exit(1);
154
+ }
155
+ await runProjectPush({
156
+ slug,
157
+ message,
158
+ branch: flags['branch'],
159
+ profile: flags['profile'],
160
+ includeDeletes: !!flags['include-deletes'],
161
+ dryRun: !!flags['dry-run'],
162
+ });
163
+ break;
164
+ }
165
+ case 'help':
166
+ case '--help':
167
+ case '-h':
168
+ case undefined:
169
+ console.log(HELP);
170
+ break;
171
+ default:
172
+ console.error(`Unknown command: ${cmd}\n`);
173
+ console.error(HELP);
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ main().catch((err) => {
179
+ console.error(`hiveku-sync: ${err.message}`);
180
+ if (process.env.HIVEKU_SYNC_DEBUG) console.error(err.stack);
181
+ process.exit(1);
182
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@hiveku-apps/sync",
3
+ "version": "0.1.0",
4
+ "description": "Sync a Hiveku account's memory/rules/skills + coder projects into a local folder. Pairs with the Hiveku MCP at core.hiveku.com/mcp so Claude Code can drive Hiveku tools per-folder.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Hiveku",
8
+ "homepage": "https://app.hiveku.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/locus2012/hiveku-sync.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/locus2012/hiveku-sync/issues"
15
+ },
16
+ "keywords": [
17
+ "hiveku",
18
+ "mcp",
19
+ "claude",
20
+ "claude-code",
21
+ "cli",
22
+ "olympus"
23
+ ],
24
+ "bin": {
25
+ "hiveku-sync": "bin/hiveku-sync.mjs"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "src",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "start": "node bin/hiveku-sync.mjs"
40
+ }
41
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `hiveku-sync init <slug>` — scaffold a per-account folder with .env.example,
3
+ * .mcp.json, CLAUDE.md, and the empty memory/rules/skills/ tree. Also appends
4
+ * the folder to the sibling hiveku.code-workspace file if one exists at the
5
+ * parent directory.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ import { writeFileAtomic, safeSlug, TYPE_TO_FOLDER } from './util.mjs';
13
+
14
+ const VALID_PROFILES = ['full', 'sales', 'marketing', 'helpdesk', 'pm', 'dev'];
15
+ const DEFAULT_BASE_URL = 'https://core.hiveku.com';
16
+
17
+ function dotenvTemplate({ profile, baseUrl }) {
18
+ return [
19
+ '# Hiveku account credentials',
20
+ '# Mint a key at https://app.hiveku.com/<account_id>/dashboard/settings',
21
+ '',
22
+ 'OLYMPUS_API_KEY=olp_replace_me',
23
+ `OLYMPUS_BASE_URL=${baseUrl}`,
24
+ `HIVEKU_PROFILE=${profile}`,
25
+ '',
26
+ ].join('\n');
27
+ }
28
+
29
+ function mcpJsonTemplate({ profile, baseUrl }) {
30
+ const url =
31
+ profile === 'full'
32
+ ? `${baseUrl}/mcp`
33
+ : `${baseUrl}/mcp?profile=${encodeURIComponent(profile)}`;
34
+ return (
35
+ JSON.stringify(
36
+ {
37
+ mcpServers: {
38
+ hiveku: {
39
+ type: 'http',
40
+ url,
41
+ headers: {
42
+ Authorization: 'Bearer ${OLYMPUS_API_KEY}',
43
+ },
44
+ },
45
+ },
46
+ },
47
+ null,
48
+ 2,
49
+ ) + '\n'
50
+ );
51
+ }
52
+
53
+ function claudeMdTemplate({ slug, profile }) {
54
+ return `# ${slug} — Hiveku Account Workspace
55
+
56
+ This folder is bound to a single Hiveku account via \`.env\` (\`OLYMPUS_API_KEY\`).
57
+ The Hiveku MCP server at \`core.hiveku.com/mcp\` is wired up in \`.mcp.json\` and
58
+ exposes the **${profile}** profile of Olympus tools.
59
+
60
+ ## Folder layout
61
+
62
+ - \`memory/\` — synced memory documents (\`hiveku-sync pull\` writes here)
63
+ - \`rules/\` — synced agent rules
64
+ - \`skills/\` — synced agent skills
65
+ - \`commands/\`, \`agents/\`, \`identity/\` — other memory types
66
+ - \`projects/<slug>/\` — coder project source pulled via \`hiveku-sync pull-project\`
67
+ - \`.hiveku/manifest.json\` — memory last-sync record
68
+ - \`.hiveku/projects.json\` — project ID ↔ folder mapping
69
+
70
+ When asked about this account, read the local files first — they are the
71
+ authoritative copy of what was in Olympus the last time \`hiveku-sync pull\`
72
+ ran. To pick up edits made elsewhere, run \`hiveku-sync pull\`.
73
+
74
+ ## Memory writes
75
+
76
+ The local memory files are read-only as far as Olympus is concerned. To persist
77
+ changes (new skill, updated rule, etc.), call the corresponding MCP tool:
78
+
79
+ - \`memory_create\` / \`memory_update\` — for memory documents, skills, rules,
80
+ commands, agents, identity entries
81
+ - \`memory_delete\` — to remove an entry
82
+
83
+ Then run \`hiveku-sync pull\` to refresh the local copies.
84
+
85
+ ## Coder projects (pull / status / push)
86
+
87
+ Each account has zero or more coder projects (Next.js, Vite, Python Lambda,
88
+ Expo). To work on one locally:
89
+
90
+ \`\`\`bash
91
+ hiveku-sync projects # list the account's projects
92
+ hiveku-sync pull-project <slug-or-id> # snapshot into ./projects/<slug>/
93
+ cd projects/<slug>
94
+ # follow the run instructions in RUN.md (npm install && npm run dev for nextjs, etc.)
95
+ \`\`\`
96
+
97
+ This is **not a git clone** — the local folder is a snapshot. To check whether
98
+ edits are in sync with the Hiveku DB, and to commit changes back to the
99
+ project's connected GitHub branch (same path the v3 sidebar uses):
100
+
101
+ \`\`\`bash
102
+ hiveku-sync status # diff local vs Hiveku DB
103
+ hiveku-sync push -m "fix: header overlap" # save + commit changed files via MCP
104
+ \`\`\`
105
+
106
+ You can also ask Claude in this workspace to:
107
+ - "Pull project <slug>"
108
+ - "Show me which files are out of sync with Hiveku"
109
+ - "Commit the changes in projects/<slug> to the dev branch with message '...'"
110
+
111
+ Claude has the \`project_files_list\`, \`project_file_get\`, \`project_files_status\`,
112
+ and \`project_commit\` MCP tools available under the **${profile}** profile.
113
+
114
+ ## Tenant scope
115
+
116
+ The API key in \`.env\` is **pinned to one Hiveku account**. You cannot switch
117
+ accounts mid-session — open a different folder instead. Each account is one
118
+ folder; folder boundaries are tenant boundaries.
119
+
120
+ ## Profile
121
+
122
+ The active tool profile is \`${profile}\`. Change \`HIVEKU_PROFILE\` in \`.env\`
123
+ and reload the MCP server in your editor to switch (\`full\`, \`sales\`,
124
+ \`marketing\`, \`helpdesk\`, \`pm\`, \`dev\`). The coder project tools
125
+ (\`project_*\`) are in the \`dev\` and \`full\` profiles.
126
+ `;
127
+ }
128
+
129
+ const GITIGNORE_TEMPLATE = ['.env', '.hiveku/', ''].join('\n');
130
+
131
+ async function appendToWorkspaceFile(workspaceFilePath, folderRelPath) {
132
+ // Idempotent: if the workspace file exists and already includes the folder, skip.
133
+ // Otherwise create or update it.
134
+ let workspace = { folders: [], settings: {} };
135
+ if (existsSync(workspaceFilePath)) {
136
+ try {
137
+ workspace = JSON.parse(await readFile(workspaceFilePath, 'utf8'));
138
+ if (!Array.isArray(workspace.folders)) workspace.folders = [];
139
+ } catch {
140
+ // Corrupt file — back it up rather than overwriting silently.
141
+ await writeFile(`${workspaceFilePath}.bak`, await readFile(workspaceFilePath));
142
+ workspace = { folders: [], settings: {} };
143
+ }
144
+ }
145
+ if (workspace.folders.some((f) => f.path === folderRelPath)) return false;
146
+ workspace.folders.push({ path: folderRelPath });
147
+ await writeFileAtomic(workspaceFilePath, JSON.stringify(workspace, null, 2) + '\n');
148
+ return true;
149
+ }
150
+
151
+ export async function runInit({ slug, parentDir, profile = 'full' }) {
152
+ if (!slug) throw new Error('slug is required, e.g. hiveku-sync init acme-corp');
153
+ if (!VALID_PROFILES.includes(profile)) {
154
+ throw new Error(
155
+ `Invalid profile '${profile}'. Valid: ${VALID_PROFILES.join(', ')}`,
156
+ );
157
+ }
158
+ const safe = safeSlug(slug);
159
+ const base = parentDir ? resolve(parentDir) : process.cwd();
160
+ const folder = join(base, safe);
161
+
162
+ if (existsSync(folder)) {
163
+ throw new Error(
164
+ `Refusing to init — ${folder} already exists. Pick a different slug or remove it first.`,
165
+ );
166
+ }
167
+
168
+ await mkdir(folder, { recursive: true });
169
+ for (const sub of Object.values(TYPE_TO_FOLDER)) {
170
+ await mkdir(join(folder, sub), { recursive: true });
171
+ }
172
+ await mkdir(join(folder, '.hiveku'), { recursive: true });
173
+
174
+ await writeFileAtomic(
175
+ join(folder, '.env.example'),
176
+ dotenvTemplate({ profile, baseUrl: DEFAULT_BASE_URL }),
177
+ );
178
+ await writeFileAtomic(
179
+ join(folder, '.mcp.json'),
180
+ mcpJsonTemplate({ profile, baseUrl: DEFAULT_BASE_URL }),
181
+ );
182
+ await writeFileAtomic(
183
+ join(folder, 'CLAUDE.md'),
184
+ claudeMdTemplate({ slug: safe, profile }),
185
+ );
186
+ await writeFileAtomic(join(folder, '.gitignore'), GITIGNORE_TEMPLATE);
187
+
188
+ const workspaceAdded = await appendToWorkspaceFile(
189
+ join(base, 'hiveku.code-workspace'),
190
+ `./${safe}`,
191
+ );
192
+
193
+ return { folder, slug: safe, profile, workspaceAdded };
194
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Minimal MCP JSON-RPC client over Streamable HTTP.
3
+ * Speaks just enough of the protocol to initialize + call tools.
4
+ */
5
+
6
+ const PROTOCOL_VERSION = '2024-11-05';
7
+
8
+ export class HivekuMcpClient {
9
+ constructor({ baseUrl, apiKey, profile = 'full' }) {
10
+ if (!baseUrl) throw new Error('baseUrl required');
11
+ if (!apiKey) throw new Error('apiKey required (OLYMPUS_API_KEY)');
12
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
13
+ this.apiKey = apiKey;
14
+ this.profile = profile;
15
+ this.sessionId = null;
16
+ this.serverInfo = null;
17
+ this.nextId = 1;
18
+ }
19
+
20
+ get endpoint() {
21
+ const url = new URL(`${this.baseUrl}/mcp`);
22
+ if (this.profile && this.profile !== 'full') {
23
+ url.searchParams.set('profile', this.profile);
24
+ }
25
+ return url.toString();
26
+ }
27
+
28
+ async _request(method, params) {
29
+ const id = this.nextId++;
30
+ const headers = {
31
+ Authorization: `Bearer ${this.apiKey}`,
32
+ 'Content-Type': 'application/json',
33
+ Accept: 'application/json',
34
+ };
35
+ if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
36
+
37
+ const res = await fetch(this.endpoint, {
38
+ method: 'POST',
39
+ headers,
40
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
41
+ });
42
+
43
+ const sessionHeader = res.headers.get('mcp-session-id');
44
+ if (sessionHeader) this.sessionId = sessionHeader;
45
+
46
+ if (!res.ok) {
47
+ const text = await res.text().catch(() => '');
48
+ throw new Error(`MCP HTTP ${res.status}: ${text.slice(0, 500)}`);
49
+ }
50
+
51
+ if (res.status === 204) return null;
52
+
53
+ const body = await res.json();
54
+ if (body.error) {
55
+ throw new Error(`MCP error ${body.error.code}: ${body.error.message}`);
56
+ }
57
+ return body.result;
58
+ }
59
+
60
+ async initialize() {
61
+ const result = await this._request('initialize', {
62
+ protocolVersion: PROTOCOL_VERSION,
63
+ capabilities: {},
64
+ clientInfo: { name: 'hiveku-sync', version: '0.1.0' },
65
+ });
66
+ this.serverInfo = result?.serverInfo || null;
67
+ await this._request('notifications/initialized', {}).catch(() => {});
68
+ return result;
69
+ }
70
+
71
+ async callTool(name, args = {}) {
72
+ const result = await this._request('tools/call', { name, arguments: args });
73
+ if (result?.isError) {
74
+ const text = result.content?.[0]?.text || 'unknown tool error';
75
+ throw new Error(`Tool ${name} errored: ${text}`);
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Convenience wrapper for tools that return one JSON-serialized text block.
82
+ * Returns the parsed payload or throws if it can't parse.
83
+ */
84
+ async callToolJson(name, args = {}) {
85
+ const result = await this.callTool(name, args);
86
+ const text = result?.content?.[0]?.text;
87
+ if (typeof text !== 'string') {
88
+ throw new Error(`Tool ${name} returned no text content`);
89
+ }
90
+ try {
91
+ return JSON.parse(text);
92
+ } catch (err) {
93
+ throw new Error(`Tool ${name} returned non-JSON content: ${text.slice(0, 200)}`);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,435 @@
1
+ /**
2
+ * `hiveku-sync` project commands — pull a Hiveku coder project into
3
+ * ./projects/<slug>/, show diff vs remote, and commit edits back via MCP.
4
+ *
5
+ * No native git clone. The MCP server handles GitHub commits via the
6
+ * account's installed GitHub App — same code path as the v3 editor sidebar.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync } from 'node:fs';
11
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
12
+ import { dirname, join, relative, sep } from 'node:path';
13
+
14
+ import { HivekuMcpClient } from './mcp.mjs';
15
+ import {
16
+ loadDotenv,
17
+ readEnv,
18
+ safeSlug,
19
+ writeFileAtomic,
20
+ readJson,
21
+ } from './util.mjs';
22
+
23
+ const PROJECTS_DIR = 'projects';
24
+ const PROJECTS_MANIFEST = '.hiveku/projects.json';
25
+
26
+ const IGNORE_DIRS = new Set([
27
+ 'node_modules',
28
+ '.git',
29
+ '.next',
30
+ '.turbo',
31
+ '.cache',
32
+ 'dist',
33
+ 'build',
34
+ '.expo',
35
+ '.vercel',
36
+ '.fly',
37
+ '.hiveku',
38
+ ]);
39
+
40
+ const RUN_HINTS = {
41
+ nextjs: 'npm install && npm run dev # Next.js dev server',
42
+ vite: 'npm install && npm run dev # Vite dev server',
43
+ 'react-app': 'npm install && npm run dev',
44
+ 'python-lambda': 'python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt',
45
+ expo: 'npm install && npx expo start',
46
+ static: 'npx serve .',
47
+ };
48
+
49
+ function sha256(content) {
50
+ return createHash('sha256').update(content).digest('hex');
51
+ }
52
+
53
+ async function clientFromCwd({ profileOverride } = {}) {
54
+ const baseDir = process.cwd();
55
+ const dotenv = await loadDotenv(join(baseDir, '.env'));
56
+ const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
57
+ const baseUrl = readEnv(process.env, dotenv, 'OLYMPUS_BASE_URL', 'https://core.hiveku.com');
58
+ const profile =
59
+ profileOverride || readEnv(process.env, dotenv, 'HIVEKU_PROFILE', 'full');
60
+ if (!apiKey) {
61
+ throw new Error(
62
+ `OLYMPUS_API_KEY not set. Add it to ${join(baseDir, '.env')} or export it.`,
63
+ );
64
+ }
65
+ const client = new HivekuMcpClient({ baseUrl, apiKey, profile });
66
+ await client.initialize();
67
+ return { client, baseDir, baseUrl, profile };
68
+ }
69
+
70
+ async function listAllRemoteFiles(client, projectId) {
71
+ const all = [];
72
+ let page = 1;
73
+ const limit = 500;
74
+ while (true) {
75
+ const result = await client.callToolJson('project_files_list', {
76
+ project_id: projectId,
77
+ page,
78
+ limit,
79
+ });
80
+ const batch = result?.data || [];
81
+ all.push(...batch);
82
+ const totalPages = result?.pagination?.total_pages ?? 1;
83
+ if (page >= totalPages || batch.length === 0) break;
84
+ page += 1;
85
+ }
86
+ return all;
87
+ }
88
+
89
+ async function walkLocalFiles(rootDir) {
90
+ const out = [];
91
+ async function recurse(dir) {
92
+ let entries;
93
+ try {
94
+ entries = await readdir(dir, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.name.startsWith('.')) continue;
100
+ if (IGNORE_DIRS.has(entry.name)) continue;
101
+ const full = join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ await recurse(full);
104
+ } else if (entry.isFile()) {
105
+ out.push(full);
106
+ }
107
+ }
108
+ }
109
+ await recurse(rootDir);
110
+ return out;
111
+ }
112
+
113
+ async function buildLocalManifest(rootDir) {
114
+ const files = await walkLocalFiles(rootDir);
115
+ const manifest = [];
116
+ for (const abs of files) {
117
+ const rel = relative(rootDir, abs).split(sep).join('/');
118
+ const buf = await readFile(abs);
119
+ manifest.push({ path: rel, sha256: sha256(buf.toString('utf8')) });
120
+ }
121
+ return manifest;
122
+ }
123
+
124
+ function runHintForProjectType(type) {
125
+ return RUN_HINTS[type] || `# Unknown project_type "${type}". Inspect package.json/requirements.txt to find the dev command.`;
126
+ }
127
+
128
+ async function readProjectsManifest(baseDir) {
129
+ const data = await readJson(join(baseDir, PROJECTS_MANIFEST));
130
+ return data && typeof data === 'object' ? data : { projects: {} };
131
+ }
132
+
133
+ async function writeProjectsManifest(baseDir, manifest) {
134
+ await writeFileAtomic(join(baseDir, PROJECTS_MANIFEST), JSON.stringify(manifest, null, 2));
135
+ }
136
+
137
+ async function resolveProject(client, slugOrId) {
138
+ // Accept either UUID (use directly) or slug (look up via list_projects).
139
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
140
+ if (UUID_RE.test(slugOrId)) {
141
+ const result = await client.callToolJson('get_project', { project_id: slugOrId });
142
+ return result?.data || result;
143
+ }
144
+ const result = await client.callToolJson('list_projects', { search: slugOrId });
145
+ const list = result?.data || [];
146
+ const match =
147
+ list.find((p) => p.slug === slugOrId) ||
148
+ list.find((p) => p.name?.toLowerCase() === slugOrId.toLowerCase()) ||
149
+ list[0];
150
+ if (!match) {
151
+ throw new Error(`No project found matching "${slugOrId}"`);
152
+ }
153
+ return match;
154
+ }
155
+
156
+ export async function runProjectsList({ profile } = {}) {
157
+ const { client } = await clientFromCwd({ profileOverride: profile });
158
+ const result = await client.callToolJson('list_projects', {});
159
+ const list = result?.data || [];
160
+ if (list.length === 0) {
161
+ console.log('No projects on this account.');
162
+ return;
163
+ }
164
+ console.log(`${list.length} project${list.length !== 1 ? 's' : ''}:\n`);
165
+ for (const p of list) {
166
+ const type = p.project_type || '?';
167
+ const slug = p.slug || p.name || p.id;
168
+ console.log(` ${slug.padEnd(32)} ${type.padEnd(14)} ${p.id}`);
169
+ }
170
+ }
171
+
172
+ export async function runPullProject({ slugOrId, profile, branch }) {
173
+ if (!slugOrId) throw new Error('Provide a project slug or UUID');
174
+ const { client, baseDir } = await clientFromCwd({ profileOverride: profile });
175
+ const project = await resolveProject(client, slugOrId);
176
+ const projectId = project.id;
177
+ const localSlug = safeSlug(project.slug || project.name || projectId);
178
+ const dest = join(baseDir, PROJECTS_DIR, localSlug);
179
+
180
+ console.log(`Pulling ${project.name || project.slug || projectId} (type=${project.project_type || '?'})`);
181
+ console.log(` Project ID: ${projectId}`);
182
+ console.log(` Destination: ${dest}`);
183
+
184
+ const files = await listAllRemoteFiles(client, projectId);
185
+ if (files.length === 0) {
186
+ console.log(' No files to pull (project is empty).');
187
+ }
188
+
189
+ let written = 0;
190
+ let skipped = 0;
191
+ for (const fileMeta of files) {
192
+ const result = await client.callToolJson('project_file_get', {
193
+ project_id: projectId,
194
+ file_path: fileMeta.file_path,
195
+ });
196
+ const fileBody = result?.file || result?.data?.file || result?.data;
197
+ if (!fileBody || typeof fileBody.file_content !== 'string') {
198
+ skipped += 1;
199
+ continue;
200
+ }
201
+ let contentBuf;
202
+ if (fileBody.file_content.startsWith('__BINARY_BASE64__')) {
203
+ contentBuf = Buffer.from(
204
+ fileBody.file_content.substring('__BINARY_BASE64__'.length),
205
+ 'base64',
206
+ );
207
+ } else {
208
+ contentBuf = Buffer.from(fileBody.file_content, 'utf8');
209
+ }
210
+ const outPath = join(dest, fileMeta.file_path);
211
+ await mkdir(dirname(outPath), { recursive: true });
212
+ await writeFileAtomic(outPath, contentBuf);
213
+ written += 1;
214
+ }
215
+
216
+ // Drop a RUN.md keyed off project_type so the user knows how to start it.
217
+ const runMd = [
218
+ `# ${project.name || project.slug || projectId}`,
219
+ '',
220
+ `Project type: \`${project.project_type || 'unknown'}\``,
221
+ `Hiveku project id: \`${projectId}\``,
222
+ branch ? `Tracking branch: \`${branch}\`` : '',
223
+ '',
224
+ '## Run locally',
225
+ '',
226
+ '```bash',
227
+ runHintForProjectType(project.project_type),
228
+ '```',
229
+ '',
230
+ '## Commit changes back',
231
+ '',
232
+ 'This folder is NOT a git clone — it\'s a snapshot. Use the Hiveku MCP `project_commit` tool',
233
+ 'to write edits back to the project\'s GitHub branch (same path the v3 sidebar uses).',
234
+ '',
235
+ 'From this folder:',
236
+ '',
237
+ '```bash',
238
+ `hiveku-sync status # diff local vs remote`,
239
+ `hiveku-sync push -m "your message" # save + commit changed files via MCP`,
240
+ '```',
241
+ '',
242
+ 'Or ask Claude in this workspace to commit the changed files for you.',
243
+ '',
244
+ ]
245
+ .filter((line) => line !== undefined)
246
+ .join('\n');
247
+ await writeFileAtomic(join(dest, 'RUN.md'), runMd);
248
+
249
+ // Persist project metadata so status/push know which project this folder maps to.
250
+ const manifest = await readProjectsManifest(baseDir);
251
+ manifest.projects = manifest.projects || {};
252
+ manifest.projects[localSlug] = {
253
+ project_id: projectId,
254
+ name: project.name || project.slug || localSlug,
255
+ project_type: project.project_type || null,
256
+ branch: branch || null,
257
+ last_pull_at: new Date().toISOString(),
258
+ };
259
+ await writeProjectsManifest(baseDir, manifest);
260
+
261
+ console.log(` Wrote ${written} file${written !== 1 ? 's' : ''}${skipped ? ` (${skipped} skipped — no content)` : ''}`);
262
+ console.log(` Run hints in ${join(dest, 'RUN.md')}`);
263
+ return { dest, written, skipped, projectId, slug: localSlug };
264
+ }
265
+
266
+ async function findProjectFolder({ slug, baseDir }) {
267
+ // Allow status/push to be called from inside ./projects/<slug>/, ./<slug>/,
268
+ // or the account root with an explicit slug arg.
269
+ const manifest = await readProjectsManifest(baseDir);
270
+ const known = manifest.projects || {};
271
+
272
+ if (slug) {
273
+ if (known[slug]) return { slug, info: known[slug], folder: join(baseDir, PROJECTS_DIR, slug) };
274
+ throw new Error(`Unknown project slug "${slug}". Run \`hiveku-sync projects\` to list, or pull-project first.`);
275
+ }
276
+
277
+ // Try to auto-detect from cwd.
278
+ const cwd = process.cwd();
279
+ const projectsRoot = join(baseDir, PROJECTS_DIR);
280
+ if (cwd.startsWith(projectsRoot + sep)) {
281
+ const rel = relative(projectsRoot, cwd).split(sep)[0];
282
+ if (known[rel]) return { slug: rel, info: known[rel], folder: join(projectsRoot, rel) };
283
+ }
284
+ throw new Error(
285
+ 'Could not determine which project. cd into ./projects/<slug>/ or pass an explicit slug.',
286
+ );
287
+ }
288
+
289
+ function summarizeDiff(diff) {
290
+ return {
291
+ changed: diff.changed?.length || 0,
292
+ only_local: diff.only_local?.length || 0,
293
+ only_remote: diff.only_remote?.length || 0,
294
+ same: diff.same_count || 0,
295
+ };
296
+ }
297
+
298
+ export async function runProjectStatus({ slug, profile, json = false }) {
299
+ // Resolve baseDir as the account folder. If we're inside ./projects/<x>/,
300
+ // walk up two levels. Otherwise treat cwd as the account folder.
301
+ const cwd = process.cwd();
302
+ const projectsIdx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
303
+ const baseDir =
304
+ projectsIdx > -1 ? cwd.slice(0, projectsIdx) : cwd;
305
+
306
+ const { client } = await clientFromCwd({ profileOverride: profile });
307
+ const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
308
+
309
+ if (!existsSync(folder)) {
310
+ throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
311
+ }
312
+
313
+ const localManifest = await buildLocalManifest(folder);
314
+ const diff = (await client.callToolJson('project_files_status', {
315
+ project_id: info.project_id,
316
+ local: localManifest,
317
+ })).data;
318
+
319
+ if (json) {
320
+ console.log(JSON.stringify({ slug: resolvedSlug, project_id: info.project_id, ...diff }, null, 2));
321
+ return diff;
322
+ }
323
+
324
+ const sum = summarizeDiff(diff);
325
+ console.log(`Project: ${resolvedSlug} (${info.project_id})`);
326
+ console.log(` changed: ${sum.changed}`);
327
+ console.log(` only_local: ${sum.only_local}`);
328
+ console.log(` only_remote: ${sum.only_remote}`);
329
+ console.log(` unchanged: ${sum.same}`);
330
+ if (sum.changed > 0) {
331
+ console.log('\nChanged files:');
332
+ for (const f of diff.changed.slice(0, 20)) console.log(` M ${f.path}`);
333
+ if (diff.changed.length > 20) console.log(` ... and ${diff.changed.length - 20} more`);
334
+ }
335
+ if (sum.only_local > 0) {
336
+ console.log('\nLocal-only (will be added on push):');
337
+ for (const f of diff.only_local.slice(0, 20)) console.log(` + ${f.path}`);
338
+ if (diff.only_local.length > 20) console.log(` ... and ${diff.only_local.length - 20} more`);
339
+ }
340
+ if (sum.only_remote > 0) {
341
+ console.log('\nRemote-only (deleted locally or never pulled — pass --include-deletes to push to commit deletions):');
342
+ for (const f of diff.only_remote.slice(0, 20)) console.log(` - ${f.path}`);
343
+ if (diff.only_remote.length > 20) console.log(` ... and ${diff.only_remote.length - 20} more`);
344
+ }
345
+ return diff;
346
+ }
347
+
348
+ export async function runProjectPush({
349
+ slug,
350
+ message,
351
+ branch,
352
+ profile,
353
+ includeDeletes = false,
354
+ dryRun = false,
355
+ }) {
356
+ if (!message || !message.trim()) {
357
+ throw new Error('Provide a commit message via -m "..."');
358
+ }
359
+
360
+ const cwd = process.cwd();
361
+ const projectsIdx = cwd.indexOf(`${sep}${PROJECTS_DIR}${sep}`);
362
+ const baseDir = projectsIdx > -1 ? cwd.slice(0, projectsIdx) : cwd;
363
+
364
+ const { client } = await clientFromCwd({ profileOverride: profile });
365
+ const { slug: resolvedSlug, info, folder } = await findProjectFolder({ slug, baseDir });
366
+
367
+ if (!existsSync(folder)) {
368
+ throw new Error(`Project folder ${folder} doesn't exist — run pull-project first.`);
369
+ }
370
+
371
+ const localManifest = await buildLocalManifest(folder);
372
+ const diff = (await client.callToolJson('project_files_status', {
373
+ project_id: info.project_id,
374
+ local: localManifest,
375
+ })).data;
376
+
377
+ const toCommit = [
378
+ ...(diff.changed || []).map((c) => c.path),
379
+ ...(diff.only_local || []).map((c) => c.path),
380
+ ];
381
+ const toDelete = includeDeletes ? (diff.only_remote || []).map((c) => c.path) : [];
382
+
383
+ if (toCommit.length === 0 && toDelete.length === 0) {
384
+ console.log('Nothing to commit — local is in sync with remote.');
385
+ return { skipped: true };
386
+ }
387
+
388
+ // Load file contents from disk for everything we're committing.
389
+ const files = [];
390
+ for (const path of toCommit) {
391
+ const abs = join(folder, path);
392
+ let content;
393
+ let encoding = 'utf-8';
394
+ const data = await readFile(abs);
395
+ // Detect text-vs-binary: try utf-8 round-trip, fall back to base64 on
396
+ // replacement chars (matches the heuristic the builder editor uses).
397
+ const asUtf8 = data.toString('utf8');
398
+ if (asUtf8.includes('�')) {
399
+ content = data.toString('base64');
400
+ encoding = 'base64';
401
+ } else {
402
+ content = asUtf8;
403
+ }
404
+ files.push({ path, content, encoding });
405
+ }
406
+
407
+ if (dryRun) {
408
+ console.log(`[dry run] would commit ${files.length} file${files.length !== 1 ? 's' : ''} and delete ${toDelete.length}.`);
409
+ return { dryRun: true, files: files.map((f) => f.path), deletedFiles: toDelete };
410
+ }
411
+
412
+ console.log(`Committing ${files.length} file${files.length !== 1 ? 's' : ''}${toDelete.length ? ` + ${toDelete.length} deletion${toDelete.length !== 1 ? 's' : ''}` : ''}…`);
413
+ const result = await client.callToolJson('project_commit', {
414
+ project_id: info.project_id,
415
+ message: message.trim(),
416
+ branch: branch || info.branch || undefined,
417
+ files,
418
+ deletedFiles: toDelete.length > 0 ? toDelete : undefined,
419
+ });
420
+ const data = result?.data || result;
421
+ console.log(` ✓ commit_sha: ${data?.commit_sha || '(none)'}`);
422
+ console.log(` branch: ${data?.branch || '(default)'}`);
423
+
424
+ // Update last_push timestamp in the manifest.
425
+ const manifest = await readProjectsManifest(baseDir);
426
+ manifest.projects = manifest.projects || {};
427
+ manifest.projects[resolvedSlug] = {
428
+ ...(manifest.projects[resolvedSlug] || { project_id: info.project_id }),
429
+ last_push_at: new Date().toISOString(),
430
+ last_commit_sha: data?.commit_sha || null,
431
+ };
432
+ await writeProjectsManifest(baseDir, manifest);
433
+
434
+ return data;
435
+ }
package/src/pull.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `hiveku-sync pull` — fetches memory/rules/skills via the MCP memory_list tool
3
+ * and writes them as local .md files under the current folder.
4
+ *
5
+ * Safety: refuses to overwrite when the .env API key no longer matches the
6
+ * manifest's recorded key preview (prevents pulling Account A's data into a
7
+ * folder previously synced for Account B — the 2026-05-11 cross-tenant bug
8
+ * class).
9
+ */
10
+
11
+ import { join } from 'node:path';
12
+
13
+ import { HivekuMcpClient } from './mcp.mjs';
14
+ import {
15
+ loadDotenv,
16
+ readEnv,
17
+ safeSlug,
18
+ writeFileAtomic,
19
+ readJson,
20
+ toFrontmatter,
21
+ extractDepartmentTag,
22
+ TYPE_TO_FOLDER,
23
+ SUPPORTED_TYPES,
24
+ } from './util.mjs';
25
+
26
+ const MANIFEST_PATH = '.hiveku/manifest.json';
27
+
28
+ function keyPreview(apiKey) {
29
+ return apiKey.slice(0, 10);
30
+ }
31
+
32
+ async function fetchType(client, type) {
33
+ const result = await client.callToolJson('memory_list', { type });
34
+ if (!result?.data || !Array.isArray(result.data)) {
35
+ throw new Error(`memory_list(${type}) returned unexpected shape`);
36
+ }
37
+ return result.data;
38
+ }
39
+
40
+ function entryFilename(entry) {
41
+ const name = entry.name || entry.domain.replace(/^_[^:]+:/, '');
42
+ return `${safeSlug(name)}.md`;
43
+ }
44
+
45
+ function renderEntry(entry, type) {
46
+ const body = entry.content ?? '';
47
+ const department = extractDepartmentTag(body);
48
+ const frontmatter = toFrontmatter({
49
+ id: entry.id,
50
+ name: entry.name,
51
+ type,
52
+ domain: entry.domain,
53
+ department,
54
+ project_id: entry.project_id,
55
+ version: entry.version,
56
+ updated_at: entry.updated_at,
57
+ });
58
+ return `${frontmatter}${body}`;
59
+ }
60
+
61
+ export async function runPull({ baseDir = process.cwd(), profile } = {}) {
62
+ const envPath = join(baseDir, '.env');
63
+ const dotenv = await loadDotenv(envPath);
64
+ const apiKey = readEnv(process.env, dotenv, 'OLYMPUS_API_KEY');
65
+ const baseUrl = readEnv(
66
+ process.env,
67
+ dotenv,
68
+ 'OLYMPUS_BASE_URL',
69
+ 'https://core.hiveku.com',
70
+ );
71
+ const activeProfile = profile || readEnv(process.env, dotenv, 'HIVEKU_PROFILE', 'full');
72
+
73
+ if (!apiKey) {
74
+ throw new Error(
75
+ `OLYMPUS_API_KEY not set. Add it to ${envPath} or export it before running.`,
76
+ );
77
+ }
78
+
79
+ // Manifest pin: refuse if the key changed under us.
80
+ const manifestPath = join(baseDir, MANIFEST_PATH);
81
+ const manifest = await readJson(manifestPath);
82
+ if (manifest?.api_key_preview && manifest.api_key_preview !== keyPreview(apiKey)) {
83
+ throw new Error(
84
+ `Refusing to pull — API key in .env (preview ${keyPreview(apiKey)}) does not match ` +
85
+ `the manifest at ${manifestPath} (preview ${manifest.api_key_preview}). ` +
86
+ `If you really meant to re-bind this folder to a different account, delete ` +
87
+ `${manifestPath} first.`,
88
+ );
89
+ }
90
+
91
+ const client = new HivekuMcpClient({ baseUrl, apiKey, profile: activeProfile });
92
+ await client.initialize();
93
+
94
+ const summary = { total: 0, by_type: {}, errors: [] };
95
+ const writtenFiles = new Set();
96
+
97
+ for (const type of SUPPORTED_TYPES) {
98
+ const folder = TYPE_TO_FOLDER[type];
99
+ let entries;
100
+ try {
101
+ entries = await fetchType(client, type);
102
+ } catch (err) {
103
+ summary.errors.push({ type, message: err.message });
104
+ continue;
105
+ }
106
+ summary.by_type[type] = entries.length;
107
+ summary.total += entries.length;
108
+
109
+ for (const entry of entries) {
110
+ const filename = entryFilename(entry);
111
+ const fullPath = join(baseDir, folder, filename);
112
+ await writeFileAtomic(fullPath, renderEntry(entry, type));
113
+ writtenFiles.add(`${folder}/${filename}`);
114
+ }
115
+ }
116
+
117
+ // v1: don't delete stale files; just record them in the manifest so the
118
+ // user can prune manually if they want. Avoids losing local edits.
119
+ const previouslySeen = new Set(manifest?.files ?? []);
120
+ const removedRemotely = [...previouslySeen].filter((f) => !writtenFiles.has(f));
121
+
122
+ await writeFileAtomic(
123
+ manifestPath,
124
+ JSON.stringify(
125
+ {
126
+ api_key_preview: keyPreview(apiKey),
127
+ base_url: baseUrl,
128
+ profile: activeProfile,
129
+ last_sync_at: new Date().toISOString(),
130
+ counts: summary.by_type,
131
+ files: [...writtenFiles].sort(),
132
+ removed_remotely_since_last_sync: removedRemotely,
133
+ },
134
+ null,
135
+ 2,
136
+ ),
137
+ );
138
+
139
+ return { ...summary, removed_remotely: removedRemotely, profile: activeProfile, baseUrl };
140
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Shared helpers for hiveku-sync.
3
+ */
4
+
5
+ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ /** Parse a .env file into a plain object. Strips quotes, ignores comments. */
10
+ export async function loadDotenv(envPath) {
11
+ if (!existsSync(envPath)) return {};
12
+ const raw = await readFile(envPath, 'utf8');
13
+ const out = {};
14
+ for (const line of raw.split('\n')) {
15
+ const trimmed = line.trim();
16
+ if (!trimmed || trimmed.startsWith('#')) continue;
17
+ const eq = trimmed.indexOf('=');
18
+ if (eq === -1) continue;
19
+ const key = trimmed.slice(0, eq).trim();
20
+ let value = trimmed.slice(eq + 1).trim();
21
+ if (
22
+ (value.startsWith('"') && value.endsWith('"')) ||
23
+ (value.startsWith("'") && value.endsWith("'"))
24
+ ) {
25
+ value = value.slice(1, -1);
26
+ }
27
+ out[key] = value;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Resolve required env value from process.env first, then a parsed .env map. */
33
+ export function readEnv(processEnv, dotenv, key, fallback) {
34
+ return processEnv[key] ?? dotenv[key] ?? fallback;
35
+ }
36
+
37
+ /** Make a filesystem-safe filename slug from an arbitrary string. */
38
+ export function safeSlug(name) {
39
+ return (
40
+ String(name || 'unnamed')
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9._-]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .slice(0, 100) || 'unnamed'
45
+ );
46
+ }
47
+
48
+ /** Atomic write — write to <path>.tmp then rename. */
49
+ export async function writeFileAtomic(path, contents) {
50
+ await mkdir(dirname(path), { recursive: true });
51
+ const tmp = `${path}.tmp`;
52
+ await writeFile(tmp, contents, 'utf8');
53
+ await rename(tmp, path);
54
+ }
55
+
56
+ /** Read JSON file or return null on missing/parse error. */
57
+ export async function readJson(path) {
58
+ if (!existsSync(path)) return null;
59
+ try {
60
+ return JSON.parse(await readFile(path, 'utf8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** YAML frontmatter encoder for scalar fields. */
67
+ export function toFrontmatter(fields) {
68
+ const lines = ['---'];
69
+ for (const [k, v] of Object.entries(fields)) {
70
+ if (v === undefined || v === null) continue;
71
+ const s = String(v).replace(/"/g, '\\"');
72
+ lines.push(`${k}: "${s}"`);
73
+ }
74
+ lines.push('---', '');
75
+ return lines.join('\n');
76
+ }
77
+
78
+ /** Map memory `type` → folder name. */
79
+ export const TYPE_TO_FOLDER = {
80
+ memory: 'memory',
81
+ rule: 'rules',
82
+ skill: 'skills',
83
+ command: 'commands',
84
+ agent: 'agents',
85
+ identity: 'identity',
86
+ };
87
+
88
+ export const SUPPORTED_TYPES = Object.keys(TYPE_TO_FOLDER);
89
+
90
+ /** Extract a department tag from content. Looks for HTML or YAML markers. */
91
+ export function extractDepartmentTag(content) {
92
+ if (!content) return null;
93
+ const htmlMatch = content.match(/<!--\s*department:\s*([a-z0-9_-]+)\s*-->/i);
94
+ if (htmlMatch) return htmlMatch[1];
95
+ const yamlMatch = content.match(/^department:\s*["']?([a-z0-9_-]+)["']?\s*$/im);
96
+ if (yamlMatch) return yamlMatch[1];
97
+ return null;
98
+ }
99
+
100
+ /** Resolve account base directory (default: ./). */
101
+ export function defaultBaseDir(cwd = process.cwd()) {
102
+ return cwd;
103
+ }
104
+
105
+ export { join };