botrun-mcli 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,234 @@
1
+ # bm — Git-backed Memory CLI for Agents
2
+
3
+ `bm` manages persistent memory for AI agents across ephemeral VMs. Memories are stored as files in Git repos (GitHub / GitLab), and `bm` handles the git plumbing — clone, sync, and scope management. Agents read/write memory files directly using their own tools.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx bm --help
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # 1. Add a memory scope (bind token via env var name)
15
+ npx bm config add-scope my-notes \
16
+ --repo github.com/your-org/agent-memory \
17
+ --token-env MY_GITHUB_TOKEN \
18
+ --description "My personal notes" \
19
+ --access readwrite
20
+
21
+ # 2. Set the token
22
+ export MY_GITHUB_TOKEN=ghp_xxxxx
23
+
24
+ # 3. Clone the repo
25
+ npx bm memory init
26
+
27
+ # 4. Agent reads/writes files at the local path...
28
+
29
+ # 5. Push changes back
30
+ npx bm memory sync
31
+ ```
32
+
33
+ ## Concepts
34
+
35
+ ### Scope
36
+
37
+ A **scope** is a logical name that maps to a git repo. Each agent can have multiple scopes pointing to different repos.
38
+
39
+ ```bash
40
+ npx bm config add-scope my-notes \
41
+ --repo github.com/org/my-memory \
42
+ --token-env BM_TOKEN_NOTES \
43
+ --description "Personal research notes" \
44
+ --access readwrite
45
+ ```
46
+
47
+ ### Multi-Repo Architecture
48
+
49
+ Different scopes can point to different repos. Permissions are controlled by Git provider tokens — not by `bm`. Each scope binds to its own token via `--token-env`, enabling per-repo permission control.
50
+
51
+ ```bash
52
+ # Director agent setup:
53
+ # Read-write token for personal repo
54
+ npx bm config add-scope director \
55
+ --repo github.com/org/director-memory \
56
+ --token-env BM_TOKEN_DIRECTOR \
57
+ --description "Director personal research" \
58
+ --access readwrite
59
+
60
+ # Read-only token for team repos
61
+ npx bm config add-scope team1 \
62
+ --repo github.com/org/team1-memory \
63
+ --token-env BM_TOKEN_TEAMS \
64
+ --description "Team 1 memory" \
65
+ --access readonly
66
+
67
+ npx bm config add-scope team2 \
68
+ --repo github.com/org/team2-memory \
69
+ --token-env BM_TOKEN_TEAMS \
70
+ --description "Team 2 memory" \
71
+ --access readonly
72
+ ```
73
+
74
+ Create separate GitHub Fine-grained PATs with different permissions:
75
+ - `BM_TOKEN_DIRECTOR` → Contents: Read and write (only `director-memory` repo)
76
+ - `BM_TOKEN_TEAMS` → Contents: Read-only (only `team1-memory` + `team2-memory` repos)
77
+
78
+ This way, even if a user modifies the config, they can't write to repos their token doesn't allow.
79
+
80
+ ## Config
81
+
82
+ ### Base Path
83
+
84
+ All `bm` data lives under a single base directory:
85
+
86
+ ```
87
+ ~/.botrun/bm/ ← default base path
88
+ ├── config.json ← scope definitions
89
+ └── data/
90
+ ├── my-notes/ ← git clone of my-notes scope
91
+ ├── team1/ ← git clone of team1 scope
92
+ └── team2/ ← git clone of team2 scope
93
+ ```
94
+
95
+ Override with CLI option or environment variable:
96
+
97
+ ```bash
98
+ npx bm --bm-path /tmp/test-bm memory init # CLI option (highest priority)
99
+ BM_PATH=/custom/path npx bm memory init # environment variable
100
+ ```
101
+
102
+ Priority: `--bm-path` > `BM_PATH` > `~/.botrun/bm/`
103
+
104
+ ### Config File
105
+
106
+ Located at `<BM_PATH>/config.json` (default: `~/.botrun/bm/config.json`).
107
+
108
+ Override config path independently with: `BM_CONFIG=/path/to/config.json`
109
+
110
+ ```json
111
+ {
112
+ "scopes": {
113
+ "my-notes": {
114
+ "repo": "github.com/org/member1-memory",
115
+ "token_env": "BM_TOKEN_NOTES",
116
+ "description": "Personal research notes",
117
+ "access": "readwrite"
118
+ },
119
+ "team1": {
120
+ "repo": "github.com/org/team1-memory",
121
+ "branch": "dev",
122
+ "token_env": "BM_TOKEN_TEAMS",
123
+ "description": "Team 1 memory",
124
+ "access": "readonly"
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ | Field | Required | Description |
131
+ |-------|----------|-------------|
132
+ | `repo` | yes | Git repo URL (without `https://`) |
133
+ | `branch` | no | Git branch to use. Omit = repo default branch |
134
+ | `token_env` | no | Env var name for this scope's token (for per-repo permission control) |
135
+ | `description` | no | Description for agent context |
136
+ | `access` | no | Access hint for agent: `readwrite` or `readonly` (default: `readwrite`) |
137
+ | `provider` | no | `github` or `gitlab`. Auto-detected from URL |
138
+
139
+ ### Config Commands
140
+
141
+ ```bash
142
+ npx bm config add-scope <name> --repo <url> [--branch <branch>] [--token-env <envVar>] [--description <text>] [--access <mode>]
143
+ npx bm config remove-scope <name>
144
+ npx bm config show
145
+ ```
146
+
147
+ ## Environment Variables
148
+
149
+ | Variable | Purpose |
150
+ |----------|---------|
151
+ | `BM_PATH` | Base directory for all bm data (default: `~/.botrun/bm`) |
152
+ | `BM_CONFIG` | Config file path (overrides `<BM_PATH>/config.json`) |
153
+
154
+ Each scope's token is configured via `--token-env`, which points to an environment variable name. There are no global token variables — every scope must declare its own.
155
+
156
+ ## Memory Commands
157
+
158
+ ### `npx bm memory init`
159
+
160
+ Clones all configured scope repos to `<BM_PATH>/data/<scope-name>/`. If already cloned, pulls latest.
161
+
162
+ ```json
163
+ {
164
+ "scopes": {
165
+ "my-notes": { "local": "/root/.botrun/bm/data/my-notes" },
166
+ "team1": { "local": "/root/.botrun/bm/data/team1" }
167
+ }
168
+ }
169
+ ```
170
+
171
+ ### `npx bm memory scopes`
172
+
173
+ Lists all scopes with their repo, description, access, and local filesystem path.
174
+
175
+ ```json
176
+ {
177
+ "scopes": {
178
+ "my-notes": {
179
+ "repo": "github.com/org/member1-memory",
180
+ "description": "Personal research notes",
181
+ "access": "readwrite",
182
+ "local": "/root/.botrun/bm/data/my-notes"
183
+ },
184
+ "team1": {
185
+ "repo": "github.com/org/team1-memory",
186
+ "description": "Team 1 memory",
187
+ "access": "readonly",
188
+ "local": "/root/.botrun/bm/data/team1"
189
+ }
190
+ }
191
+ }
192
+ ```
193
+
194
+ ### `npx bm memory sync`
195
+
196
+ Commits and pushes all changed memory files back to remote repos.
197
+
198
+ ```json
199
+ {
200
+ "synced": ["my-notes"],
201
+ "skipped": ["team1"]
202
+ }
203
+ ```
204
+
205
+ ## JSON Output
206
+
207
+ All commands output structured JSON, including `--help`:
208
+
209
+ ```bash
210
+ npx bm --help
211
+ npx bm config --help
212
+ npx bm memory --help
213
+ ```
214
+
215
+ ## Agent Lifecycle
216
+
217
+ ```
218
+ VM starts
219
+ → npx bm memory init # clone repos to <BM_PATH>/data/
220
+ → agent reads/writes files # using native tools (Read, Write, grep)
221
+ → npx bm memory sync # push changes
222
+ VM destroyed
223
+ ```
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ npm install
229
+ npm test # 41 tests
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "botrun-mcli",
3
+ "version": "0.1.0",
4
+ "description": "Git-backed memory CLI for AI agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "bm": "./src/bin.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ],
12
+ "scripts": {
13
+ "test": "find test -name '*.test.mjs' | xargs node --test"
14
+ },
15
+ "keywords": [
16
+ "ai",
17
+ "agent",
18
+ "memory",
19
+ "git",
20
+ "cli"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/botrun/botrun-memory-cli.git"
25
+ },
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "commander": "^13.0.0"
29
+ }
30
+ }
package/src/bin.mjs ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { getConfigPath } from './config.mjs';
5
+ import { addScope } from './commands/config/add-scope.mjs';
6
+ import { removeScope } from './commands/config/remove-scope.mjs';
7
+ import { showConfig } from './commands/config/show.mjs';
8
+ import { initMemory } from './commands/memory/init.mjs';
9
+ import { syncMemory } from './commands/memory/sync.mjs';
10
+ import { listScopes } from './commands/memory/scopes.mjs';
11
+
12
+ function jsonHelp(cmd) {
13
+ const commands = cmd.commands.map(c => ({
14
+ name: c.name(),
15
+ description: c.description(),
16
+ }));
17
+ const options = cmd.options.map(o => ({
18
+ flags: o.flags,
19
+ description: o.description,
20
+ required: o.required,
21
+ }));
22
+ return { name: cmd.name(), description: cmd.description(), commands, options };
23
+ }
24
+
25
+ const program = new Command();
26
+
27
+ program
28
+ .name('bm')
29
+ .description('Git-backed memory management for agents')
30
+ .version('0.1.0')
31
+ .helpCommand(false)
32
+ .option('--bm-path <path>', 'Base directory for all bm data')
33
+ .configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) })
34
+ .hook('preAction', (thisCommand) => {
35
+ const bmPath = thisCommand.opts().bmPath;
36
+ if (bmPath) process.env.BM_PATH = bmPath;
37
+ });
38
+
39
+ // --- config ---
40
+ const configCmd = program.command('config').description('Manage configuration');
41
+ configCmd.configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) });
42
+
43
+ configCmd
44
+ .command('add-scope <name>')
45
+ .description('Add a scope to the configuration')
46
+ .requiredOption('--repo <url>', 'Git repo URL')
47
+ .option('--branch <branch>', 'Git branch to use')
48
+ .option('--token-env <envVar>', 'Environment variable name for this scope token')
49
+ .option('--description <text>', 'Description of this scope for agent context')
50
+ .option('--access <mode>', 'Access mode hint for agent: readwrite or readonly', 'readwrite')
51
+ .action(async (name, opts) => {
52
+ const result = await addScope(name, opts, getConfigPath());
53
+ console.log(JSON.stringify(result, null, 2));
54
+ });
55
+
56
+ configCmd
57
+ .command('remove-scope <name>')
58
+ .description('Remove a scope from the configuration')
59
+ .action(async (name) => {
60
+ const result = await removeScope(name, getConfigPath());
61
+ console.log(JSON.stringify(result, null, 2));
62
+ });
63
+
64
+ configCmd
65
+ .command('show')
66
+ .description('Show current configuration')
67
+ .action(async () => {
68
+ const result = await showConfig(getConfigPath());
69
+ console.log(JSON.stringify(result, null, 2));
70
+ });
71
+
72
+ // --- memory ---
73
+ const memoryCmd = program.command('memory').description('Manage memory repos');
74
+ memoryCmd.configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) });
75
+
76
+ memoryCmd
77
+ .command('init')
78
+ .description('Clone all scope repos')
79
+ .action(async () => {
80
+ const result = await initMemory({ configPath: getConfigPath() });
81
+ console.log(JSON.stringify(result, null, 2));
82
+ });
83
+
84
+ memoryCmd
85
+ .command('sync')
86
+ .description('Sync memory changes to remote repos')
87
+ .action(async () => {
88
+ const result = await syncMemory({ configPath: getConfigPath() });
89
+ console.log(JSON.stringify(result, null, 2));
90
+ });
91
+
92
+ memoryCmd
93
+ .command('scopes')
94
+ .description('List available scopes and local paths')
95
+ .action(async () => {
96
+ const result = await listScopes({ configPath: getConfigPath() });
97
+ console.log(JSON.stringify(result, null, 2));
98
+ });
99
+
100
+ program.parse();
@@ -0,0 +1,13 @@
1
+ import { loadConfig, saveConfig } from '../../config.mjs';
2
+
3
+ export async function addScope(name, options, configPath) {
4
+ const config = await loadConfig(configPath);
5
+ const scopeEntry = { repo: options.repo };
6
+ if (options.branch) scopeEntry.branch = options.branch;
7
+ if (options.tokenEnv) scopeEntry.token_env = options.tokenEnv;
8
+ if (options.description) scopeEntry.description = options.description;
9
+ if (options.access) scopeEntry.access = options.access;
10
+ config.scopes[name] = scopeEntry;
11
+ await saveConfig(configPath, config);
12
+ return { added: name, scope: scopeEntry };
13
+ }
@@ -0,0 +1,11 @@
1
+ import { loadConfig, saveConfig } from '../../config.mjs';
2
+
3
+ export async function removeScope(name, configPath) {
4
+ const config = await loadConfig(configPath);
5
+ if (!config.scopes[name]) {
6
+ throw new Error(`Scope "${name}" not found`);
7
+ }
8
+ delete config.scopes[name];
9
+ await saveConfig(configPath, config);
10
+ return { removed: name };
11
+ }
@@ -0,0 +1,5 @@
1
+ import { loadConfig } from '../../config.mjs';
2
+
3
+ export async function showConfig(configPath) {
4
+ return await loadConfig(configPath);
5
+ }
@@ -0,0 +1,57 @@
1
+ import { mkdir, lstat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { loadConfig, getBasePath } from '../../config.mjs';
4
+ import { gitExec } from '../../git-cmd.mjs';
5
+ import { detectProvider, getProvider, resolveToken } from '../../git/provider.mjs';
6
+
7
+ async function exists(path) {
8
+ try {
9
+ await lstat(path);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ async function cloneOrPull(repo, cloneDir, token, localMode, branch) {
17
+ if (await exists(cloneDir)) {
18
+ await gitExec(['-C', cloneDir, 'pull', '--rebase']);
19
+ return;
20
+ }
21
+
22
+ let cloneUrl;
23
+ if (localMode) {
24
+ cloneUrl = repo;
25
+ } else {
26
+ const providerName = detectProvider(repo);
27
+ const provider = getProvider(providerName);
28
+ cloneUrl = provider.buildCloneUrl(repo, token);
29
+ }
30
+
31
+ const cloneArgs = ['clone', '--depth', '1'];
32
+ if (branch) cloneArgs.push('--branch', branch);
33
+ cloneArgs.push(cloneUrl, cloneDir);
34
+ await gitExec(cloneArgs);
35
+ }
36
+
37
+ export async function initMemory(options = {}) {
38
+ const configPath = options.configPath;
39
+ const dataDir = options.dataDir || join(getBasePath(), 'data');
40
+ const localMode = options.localMode || false;
41
+
42
+ const config = await loadConfig(configPath);
43
+ const result = { scopes: {} };
44
+
45
+ await mkdir(dataDir, { recursive: true });
46
+
47
+ for (const [name, scope] of Object.entries(config.scopes)) {
48
+ const cloneDir = join(dataDir, name);
49
+ const token = localMode ? undefined : resolveToken(scope);
50
+
51
+ await cloneOrPull(scope.repo, cloneDir, token, localMode, scope.branch);
52
+
53
+ result.scopes[name] = { local: cloneDir };
54
+ }
55
+
56
+ return result;
57
+ }
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { loadConfig, getBasePath } from '../../config.mjs';
4
+
5
+ export async function listScopes(options = {}) {
6
+ const config = await loadConfig(options.configPath);
7
+ const dataDir = options.dataDir || join(getBasePath(), 'data');
8
+ const result = { scopes: {} };
9
+
10
+ for (const [name, scope] of Object.entries(config.scopes)) {
11
+ const entry = { repo: scope.repo };
12
+ if (scope.description) entry.description = scope.description;
13
+ if (scope.access) entry.access = scope.access;
14
+
15
+ const scopeDir = join(dataDir, name);
16
+ try {
17
+ await lstat(scopeDir);
18
+ entry.local = scopeDir;
19
+ } catch {
20
+ entry.local = null;
21
+ }
22
+
23
+ result.scopes[name] = entry;
24
+ }
25
+
26
+ return result;
27
+ }
@@ -0,0 +1,29 @@
1
+ import { join } from 'node:path';
2
+ import { loadConfig, getBasePath } from '../../config.mjs';
3
+ import { gitExec } from '../../git-cmd.mjs';
4
+
5
+ export async function syncMemory(options = {}) {
6
+ const config = await loadConfig(options.configPath);
7
+ const dataDir = options.dataDir || join(getBasePath(), 'data');
8
+
9
+ const result = { synced: [], skipped: [] };
10
+
11
+ for (const [name, scope] of Object.entries(config.scopes)) {
12
+ const cloneDir = join(dataDir, name);
13
+
14
+ const status = await gitExec(['-C', cloneDir, 'status', '--porcelain']);
15
+
16
+ if (!status) {
17
+ result.skipped.push(name);
18
+ continue;
19
+ }
20
+
21
+ await gitExec(['-C', cloneDir, 'add', '-A']);
22
+ await gitExec(['-C', cloneDir, 'commit', '-m', `bm: update ${name} memories`]);
23
+ await gitExec(['-C', cloneDir, 'pull', '--rebase']);
24
+ await gitExec(['-C', cloneDir, 'push']);
25
+ result.synced.push(name);
26
+ }
27
+
28
+ return result;
29
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,30 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+
5
+ const DEFAULT_BASE_PATH = join(homedir(), '.botrun', 'bm');
6
+
7
+ export function getBasePath() {
8
+ return process.env.BM_PATH || DEFAULT_BASE_PATH;
9
+ }
10
+
11
+ export function getConfigPath() {
12
+ return process.env.BM_CONFIG || join(getBasePath(), 'config.json');
13
+ }
14
+
15
+ export async function loadConfig(configPath = getConfigPath()) {
16
+ try {
17
+ const content = await readFile(configPath, 'utf-8');
18
+ return JSON.parse(content);
19
+ } catch (err) {
20
+ if (err.code === 'ENOENT') {
21
+ return { scopes: {} };
22
+ }
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ export async function saveConfig(configPath = getConfigPath(), config) {
28
+ await mkdir(dirname(configPath), { recursive: true });
29
+ await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
30
+ }
@@ -0,0 +1,3 @@
1
+ export function buildCloneUrl(repo, token) {
2
+ return `https://x-access-token:${token}@${repo}.git`;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function buildCloneUrl() {
2
+ throw new Error('GitLab support not yet implemented');
3
+ }
@@ -0,0 +1,23 @@
1
+ import * as github from './github.mjs';
2
+ import * as gitlab from './gitlab.mjs';
3
+
4
+ const providers = { github, gitlab };
5
+
6
+ export function detectProvider(repoUrl) {
7
+ if (repoUrl.startsWith('github.com')) return 'github';
8
+ if (repoUrl.startsWith('gitlab.com')) return 'gitlab';
9
+ throw new Error(`Unknown git provider for URL: ${repoUrl}`);
10
+ }
11
+
12
+ export function getProvider(name) {
13
+ const provider = providers[name];
14
+ if (!provider) throw new Error(`Unknown provider: ${name}`);
15
+ // trigger "not yet implemented" for gitlab
16
+ if (name === 'gitlab') provider.buildCloneUrl();
17
+ return provider;
18
+ }
19
+
20
+ export function resolveToken(scope) {
21
+ if (!scope.token_env) return undefined;
22
+ return process.env[scope.token_env] || undefined;
23
+ }
@@ -0,0 +1,12 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function gitExec(args, options = {}) {
7
+ const { stdout } = await execFileAsync('git', args, {
8
+ maxBuffer: 10 * 1024 * 1024,
9
+ ...options,
10
+ });
11
+ return stdout.trim();
12
+ }