agentmash 0.3.0 → 0.4.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 +29 -1
- package/agentmash.mjs +341 -8
- package/crew/README.md +52 -0
- package/crew/bundle.json +223 -0
- package/crew/crew.lock.json +14 -0
- package/crew/install.mjs +210 -0
- package/crew/team-plan.mjs +725 -0
- package/hooks/mcp_launcher.mjs +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.2.0",
|
|
3
|
+
"tag": "v1.2.0",
|
|
4
|
+
"repo": "IskakovDamir/agentmash-crew",
|
|
5
|
+
"commit": "5ad6818f6fcb926ea66f7389fd00a0041d4fc1c7",
|
|
6
|
+
"files": {
|
|
7
|
+
"README.md": "fa14d0bccf7c4d4443cc22090ac7b704554339a458860a580efb5c6f09e9dcf9",
|
|
8
|
+
"bundle.json": "da036bd191cf91fecb860c88934892902755aaad28f2ed7d777560117196a1f7",
|
|
9
|
+
"catalog.json": "10ab3eefe24910560bdba50cdb13b5656350403b3cfa396573cc136ee9d8076a",
|
|
10
|
+
"install.mjs": "cb8c260e9b819ca1973fe3f0d6b659f2727cd2c6315ce41d91bdef24a6ca0327",
|
|
11
|
+
"team-plan.d.mts": "9075bc89056777dabdcf161dcf31fe530ee533c4eae4cb68d336299593f5ba0d",
|
|
12
|
+
"team-plan.mjs": "f420bc1af16cb0cfd3e95a1f0a3489daa5393d178c2e8181e95d1792dcee85df"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/crew/install.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// File-system side of the team installer: find the repository, snapshot what is on disk, apply a plan.
|
|
2
|
+
// All decisions are made by the pure functions in team-plan.mjs; this module only reads and writes.
|
|
3
|
+
//
|
|
4
|
+
// Safety rules: every path is relative to the repository root and starts with ".claude/"; we never follow a symlink
|
|
5
|
+
// inside the repository (not for the file, not for any parent directory); a plan is validated completely before the
|
|
6
|
+
// first byte is written.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { SETTINGS_PATH, assertSafePath, parseManifest, planChange, sha256Hex, statePaths, verifyBundle } from './team-plan.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Walks up from `start` to the first directory that contains a .git directory or file (worktrees, submodules).
|
|
16
|
+
* Refuses the home directory: when it is a git repository (a dotfiles checkout), its .claude/ is Claude Code's
|
|
17
|
+
* user-level configuration, which applies to every project.
|
|
18
|
+
*/
|
|
19
|
+
export function findRepoRoot(start = process.cwd()) {
|
|
20
|
+
const from = path.resolve(start);
|
|
21
|
+
if (!isDirectory(from)) throw new Error(`${from} is not a directory`);
|
|
22
|
+
for (let dir = from; ; dir = path.dirname(dir)) {
|
|
23
|
+
if (exists(path.join(dir, '.git'))) {
|
|
24
|
+
if (realpath(dir) === realpath(os.homedir())) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`the nearest git repository above ${from} is your home directory ${dir}; installing there would write into ~/.claude, Claude Code's configuration for every project. Run this inside a project repository or pass --repo <path>`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
if (path.dirname(dir) === dir) {
|
|
32
|
+
throw new Error(`${from} is not inside a git repository (no .git found in it or above it); pass --repo <path>`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function realpath(abs) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync(abs);
|
|
40
|
+
} catch {
|
|
41
|
+
return path.resolve(abs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isDirectory(abs) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.statSync(abs).isDirectory();
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function exists(abs) {
|
|
54
|
+
try {
|
|
55
|
+
fs.statSync(abs);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const toAbsolute = (root, rel) => path.join(root, ...rel.split('/'));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* lstat every component of `rel` below the repository root. Returns false when the path does not exist and true when
|
|
66
|
+
* it is a regular file. Throws on a symlink anywhere on the way, a parent that is not a directory, or a target that is
|
|
67
|
+
* not a regular file.
|
|
68
|
+
*/
|
|
69
|
+
function inspectPath(root, rel) {
|
|
70
|
+
const parts = rel.split('/');
|
|
71
|
+
let current = root;
|
|
72
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
73
|
+
current = path.join(current, parts[i]);
|
|
74
|
+
const shown = parts.slice(0, i + 1).join('/');
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
stat = fs.lstatSync(current);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT') return false;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (stat.isSymbolicLink()) throw new Error(`refusing to touch ${rel}: ${shown} is a symlink`);
|
|
83
|
+
const last = i === parts.length - 1;
|
|
84
|
+
if (!last && !stat.isDirectory()) throw new Error(`refusing to touch ${rel}: ${shown} is not a directory`);
|
|
85
|
+
if (last && !stat.isFile()) throw new Error(`refusing to touch ${rel}: it is not a regular file`);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readBytes(root, rel) {
|
|
91
|
+
return inspectPath(root, rel) ? fs.readFileSync(toAbsolute(root, rel)) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readText(root, rel) {
|
|
95
|
+
const bytes = readBytes(root, rel);
|
|
96
|
+
return bytes === null ? null : bytes.toString('utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function readManifest(root, bundle) {
|
|
100
|
+
const { manifestPath } = bundle.meta;
|
|
101
|
+
const text = readText(root, manifestPath);
|
|
102
|
+
return text === null ? null : parseManifest(text, manifestPath, bundle);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Snapshot for planChange: the manifest, settings.json and the sha256 of every path the plan may touch. */
|
|
106
|
+
export function readState(root, bundle, op) {
|
|
107
|
+
const manifest = readManifest(root, bundle);
|
|
108
|
+
const onDisk = {};
|
|
109
|
+
for (const rel of statePaths(bundle, manifest, op)) {
|
|
110
|
+
const bytes = readBytes(root, assertSafePath(rel));
|
|
111
|
+
onDisk[rel] = bytes === null ? null : sha256Hex(bytes);
|
|
112
|
+
}
|
|
113
|
+
return { manifest, settingsText: readText(root, SETTINGS_PATH), onDisk };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Removes the directories above `rel` that are now empty, up to but never including .claude itself. */
|
|
117
|
+
function pruneEmptyDirectories(root, rel) {
|
|
118
|
+
const parts = rel.split('/').slice(0, -1);
|
|
119
|
+
while (parts.length > 1) {
|
|
120
|
+
try {
|
|
121
|
+
fs.rmdirSync(toAbsolute(root, parts.join('/')));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error.code === 'ENOTEMPTY' || error.code === 'EEXIST') return;
|
|
124
|
+
if (error.code !== 'ENOENT') throw error;
|
|
125
|
+
}
|
|
126
|
+
parts.pop();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Writes a sibling temporary file, flushes it and renames it over the target. A write that fails (disk full) or is
|
|
132
|
+
* interrupted leaves the old file whole, never a truncated one that the next run would take for a local edit.
|
|
133
|
+
*/
|
|
134
|
+
function writeFile(root, rel, content) {
|
|
135
|
+
const abs = toAbsolute(root, rel);
|
|
136
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
137
|
+
const temp = `${abs}.agentmash-tmp-${process.pid}`;
|
|
138
|
+
let fd = null;
|
|
139
|
+
let created = false;
|
|
140
|
+
try {
|
|
141
|
+
fd = fs.openSync(temp, 'wx');
|
|
142
|
+
created = true;
|
|
143
|
+
fs.writeFileSync(fd, content);
|
|
144
|
+
fs.fsyncSync(fd);
|
|
145
|
+
fs.closeSync(fd);
|
|
146
|
+
fd = null;
|
|
147
|
+
fs.renameSync(temp, abs);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (fd !== null) fs.closeSync(fd);
|
|
150
|
+
if (created) fs.rmSync(temp, { force: true });
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deleteFile(root, rel) {
|
|
156
|
+
fs.rmSync(toAbsolute(root, rel), { force: true });
|
|
157
|
+
pruneEmptyDirectories(root, rel);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Applies a plan from planChange: files, then settings.json, then the manifest (so a crash leaves it conservative). */
|
|
161
|
+
export function apply(root, plan) {
|
|
162
|
+
for (const write of plan.writes) {
|
|
163
|
+
if (sha256Hex(write.content) !== write.sha256) throw new Error(`internal error: content of ${write.path} does not match its sha256`);
|
|
164
|
+
}
|
|
165
|
+
for (const rel of [...plan.writes.map((w) => w.path), ...plan.deletes, plan.manifestPath]) inspectPath(root, assertSafePath(rel));
|
|
166
|
+
inspectPath(root, SETTINGS_PATH);
|
|
167
|
+
|
|
168
|
+
for (const { path: rel, content } of plan.writes) writeFile(root, rel, content);
|
|
169
|
+
for (const rel of plan.deletes) deleteFile(root, rel);
|
|
170
|
+
|
|
171
|
+
if (plan.settings.changed) {
|
|
172
|
+
if (plan.settings.text === null) fs.rmSync(toAbsolute(root, SETTINGS_PATH), { force: true });
|
|
173
|
+
else writeFile(root, SETTINGS_PATH, plan.settings.text);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (plan.manifestAction === 'deleted') deleteFile(root, plan.manifestPath);
|
|
177
|
+
else if (plan.manifestAction !== 'unchanged') writeFile(root, plan.manifestPath, plan.manifestText);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
181
|
+
// High-level entry points for the CLI
|
|
182
|
+
|
|
183
|
+
export function loadBundle(file) {
|
|
184
|
+
let text;
|
|
185
|
+
try {
|
|
186
|
+
text = fs.readFileSync(file, 'utf8');
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new Error(`cannot read the crew bundle at ${file} (${error.code ?? error.message}); run: node scripts/build-bundle.mjs`);
|
|
189
|
+
}
|
|
190
|
+
let bundle;
|
|
191
|
+
try {
|
|
192
|
+
bundle = JSON.parse(text);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(`the crew bundle at ${file} is not valid JSON (${error.message})`);
|
|
195
|
+
}
|
|
196
|
+
return verifyBundle(bundle);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Plans `op` for the repository containing `repo` and, unless dryRun, applies it. */
|
|
200
|
+
export function runTeamOp({ repo, bundle, op, dryRun = false }) {
|
|
201
|
+
const root = findRepoRoot(repo);
|
|
202
|
+
const plan = planChange(bundle, readState(root, bundle, op), op);
|
|
203
|
+
if (!dryRun) apply(root, plan);
|
|
204
|
+
return { root, plan };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function listTeams({ repo, bundle }) {
|
|
208
|
+
const root = findRepoRoot(repo);
|
|
209
|
+
return { root, teams: readManifest(root, bundle)?.teams ?? [] };
|
|
210
|
+
}
|