@weftlabs/cli 0.25.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 +186 -0
- package/bin/weft.mjs +5 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +1104 -0
- package/dist/cli.js.map +1 -0
- package/dist/weft-skill/SKILL.md +220 -0
- package/dist/weft-skill/rules/cli.md +96 -0
- package/examples/agent-bootstrap.sh +69 -0
- package/examples/cli-quickstart.sh +7 -0
- package/package.json +55 -0
- package/scripts/install-skill.mjs +273 -0
- package/scripts/skill-paths.mjs +42 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
rmdir,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
containsSymlink,
|
|
16
|
+
home,
|
|
17
|
+
isDetectedHost,
|
|
18
|
+
skillFiles,
|
|
19
|
+
targets,
|
|
20
|
+
} from "./skill-paths.mjs";
|
|
21
|
+
|
|
22
|
+
const bundledRoot = "../dist/weft-skill/";
|
|
23
|
+
const workspaceSkill = new URL("../../skills/weft/SKILL.md", import.meta.url);
|
|
24
|
+
|
|
25
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
26
|
+
|
|
27
|
+
function markerFor(contents) {
|
|
28
|
+
const hashes = {};
|
|
29
|
+
for (const file of skillFiles) {
|
|
30
|
+
const content = contents.get(file);
|
|
31
|
+
if (content !== undefined) hashes[file] = sha256(content);
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify({ sha256: hashes });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readIfPresent(path) {
|
|
37
|
+
return readFile(path, "utf8").catch((error) => {
|
|
38
|
+
if (error.code === "ENOENT") return undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove a `weft-cli` Skill directory that an earlier version of this
|
|
45
|
+
* package installed next to the new `weft` Skill directory. Only content the
|
|
46
|
+
* package's ownership marker vouches for is removed; a user-owned or
|
|
47
|
+
* user-modified copy stays untouched.
|
|
48
|
+
*/
|
|
49
|
+
async function removeLegacySkill(skillsRootRelative, warn) {
|
|
50
|
+
const legacyRelative = join(skillsRootRelative, "weft-cli");
|
|
51
|
+
if (await containsSymlink(legacyRelative)) return;
|
|
52
|
+
const legacyDirectory = join(home, legacyRelative);
|
|
53
|
+
const markerPath = join(legacyDirectory, ".weft-cli-owner.json");
|
|
54
|
+
const skillPath = join(legacyDirectory, "SKILL.md");
|
|
55
|
+
const marker = await readIfPresent(markerPath);
|
|
56
|
+
if (marker === undefined) return;
|
|
57
|
+
const legacySkill = await readIfPresent(skillPath);
|
|
58
|
+
if (
|
|
59
|
+
legacySkill !== undefined &&
|
|
60
|
+
marker !== JSON.stringify({ sha256: sha256(legacySkill) })
|
|
61
|
+
) {
|
|
62
|
+
warn(
|
|
63
|
+
`Kept modified legacy Skill at ${skillPath}; remove it manually.`,
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (legacySkill !== undefined) await unlink(skillPath);
|
|
68
|
+
await unlink(markerPath);
|
|
69
|
+
await rm(join(legacyDirectory, ".weft-cli-next"), { force: true });
|
|
70
|
+
await rm(join(legacyDirectory, ".weft-cli-backup"), { force: true });
|
|
71
|
+
await rmdir(legacyDirectory).catch((error) => {
|
|
72
|
+
if (error.code !== "ENOTEMPTY") throw error;
|
|
73
|
+
warn(
|
|
74
|
+
`Kept non-empty legacy Skill directory at ${legacyDirectory}.`,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function installSkill({ force = false, silent = false } = {}) {
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const warn = (message) => {
|
|
82
|
+
warnings.push(message);
|
|
83
|
+
if (!silent) console.warn(message);
|
|
84
|
+
};
|
|
85
|
+
const hosts = [];
|
|
86
|
+
|
|
87
|
+
if (process.env.WEFT_SKIP_SKILL_INSTALL === "1") {
|
|
88
|
+
return { status: "skipped", reason: "opt-out", installed: 0, hosts, warnings };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!force && process.env.WEFT_FORCE_SKILL_INSTALL !== "1") {
|
|
92
|
+
const isWorkspaceInstall = await access(workspaceSkill)
|
|
93
|
+
.then(() => true)
|
|
94
|
+
.catch(() => false);
|
|
95
|
+
if (isWorkspaceInstall) {
|
|
96
|
+
return {
|
|
97
|
+
status: "skipped",
|
|
98
|
+
reason: "workspace",
|
|
99
|
+
installed: 0,
|
|
100
|
+
hosts,
|
|
101
|
+
warnings,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const skill = new Map();
|
|
107
|
+
try {
|
|
108
|
+
for (const file of skillFiles) {
|
|
109
|
+
skill.set(
|
|
110
|
+
file,
|
|
111
|
+
await readFile(new URL(bundledRoot + file, import.meta.url), "utf8"),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
warn(`Could not read the bundled Weft Skill: ${error.message}`);
|
|
116
|
+
return {
|
|
117
|
+
status: "skipped",
|
|
118
|
+
reason: "no-bundle",
|
|
119
|
+
installed: 0,
|
|
120
|
+
hosts,
|
|
121
|
+
warnings,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ownerMarker = markerFor(skill);
|
|
126
|
+
|
|
127
|
+
let installed = 0;
|
|
128
|
+
for (const [hostRoot, relativeDirectory] of targets) {
|
|
129
|
+
const directory = join(home, relativeDirectory);
|
|
130
|
+
const markerTarget = join(directory, ".weft-owner.json");
|
|
131
|
+
try {
|
|
132
|
+
if (!(await isDetectedHost(hostRoot))) continue;
|
|
133
|
+
let symlinked = await containsSymlink(relativeDirectory);
|
|
134
|
+
for (const file of [...skillFiles, ".weft-owner.json"]) {
|
|
135
|
+
if (symlinked) break;
|
|
136
|
+
symlinked = await containsSymlink(join(relativeDirectory, file));
|
|
137
|
+
}
|
|
138
|
+
if (symlinked) {
|
|
139
|
+
warn(`Skipped symlinked Skill destination: ${directory}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const existing = new Map();
|
|
144
|
+
for (const file of skillFiles) {
|
|
145
|
+
existing.set(file, await readIfPresent(join(directory, file)));
|
|
146
|
+
}
|
|
147
|
+
const marker = await readIfPresent(markerTarget);
|
|
148
|
+
if (marker !== undefined) {
|
|
149
|
+
for (const file of skillFiles) {
|
|
150
|
+
await rm(join(dirname(join(directory, file)), `.weft-next-${basename(file)}`), {
|
|
151
|
+
force: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const anyExisting = [...existing.values()].some(
|
|
157
|
+
(content) => content !== undefined,
|
|
158
|
+
);
|
|
159
|
+
const upToDate = skillFiles.every(
|
|
160
|
+
(file) => existing.get(file) === skill.get(file),
|
|
161
|
+
);
|
|
162
|
+
if (upToDate) {
|
|
163
|
+
if (marker !== undefined && marker !== ownerMarker) {
|
|
164
|
+
await writeFile(markerTarget, ownerMarker, {
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
mode: 0o600,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
await removeLegacySkill(dirname(relativeDirectory), warn);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (anyExisting) {
|
|
174
|
+
// Every file already on disk must match the hash the ownership marker
|
|
175
|
+
// recorded for it, or the incoming canonical content — the latter is
|
|
176
|
+
// exactly what a crash mid-upgrade leaves behind, so a rerun repairs
|
|
177
|
+
// it. Anything else is user-owned or user-modified and stays.
|
|
178
|
+
let recorded;
|
|
179
|
+
try {
|
|
180
|
+
recorded = marker === undefined ? undefined : JSON.parse(marker).sha256;
|
|
181
|
+
} catch {
|
|
182
|
+
recorded = undefined;
|
|
183
|
+
}
|
|
184
|
+
const packageOwned =
|
|
185
|
+
typeof recorded === "object" &&
|
|
186
|
+
recorded !== null &&
|
|
187
|
+
skillFiles.every((file) => {
|
|
188
|
+
const content = existing.get(file);
|
|
189
|
+
if (content === undefined) return true;
|
|
190
|
+
return (
|
|
191
|
+
recorded[file] === sha256(content) || content === skill.get(file)
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
if (!packageOwned) {
|
|
195
|
+
warn(
|
|
196
|
+
`Kept existing Skill at ${directory}. Remove it and run \`weft skill install\` to use the weft Skill.`,
|
|
197
|
+
);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Stage every file first, then rename them all, so a crash never
|
|
201
|
+
// leaves a half-written file and at worst leaves the mixed
|
|
202
|
+
// old/canonical state the ownership check above repairs.
|
|
203
|
+
const staged = [];
|
|
204
|
+
for (const file of skillFiles) {
|
|
205
|
+
const target = join(directory, file);
|
|
206
|
+
await mkdir(dirname(target), { recursive: true });
|
|
207
|
+
const temporary = join(dirname(target), `.weft-next-${basename(file)}`);
|
|
208
|
+
await writeFile(temporary, skill.get(file), {
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
mode: 0o600,
|
|
211
|
+
});
|
|
212
|
+
staged.push([temporary, target]);
|
|
213
|
+
}
|
|
214
|
+
for (const [temporary, target] of staged) {
|
|
215
|
+
await rename(temporary, target);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
await mkdir(directory, { recursive: true });
|
|
219
|
+
await rm(markerTarget, { force: true });
|
|
220
|
+
await writeFile(markerTarget, ownerMarker, {
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
flag: "wx",
|
|
223
|
+
mode: 0o600,
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
for (const file of skillFiles) {
|
|
227
|
+
const target = join(directory, file);
|
|
228
|
+
await mkdir(dirname(target), { recursive: true });
|
|
229
|
+
await writeFile(target, skill.get(file), {
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
flag: "wx",
|
|
232
|
+
mode: 0o600,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await rm(markerTarget, { force: true });
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await writeFile(markerTarget, ownerMarker, {
|
|
242
|
+
encoding: "utf8",
|
|
243
|
+
mode: 0o600,
|
|
244
|
+
});
|
|
245
|
+
installed += 1;
|
|
246
|
+
hosts.push(relativeDirectory);
|
|
247
|
+
await removeLegacySkill(dirname(relativeDirectory), warn);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
warn(
|
|
250
|
+
`Could not install the Weft Skill at ${directory}: ${error.message}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { status: "ok", installed, hosts, warnings };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Still runnable as a script (`node scripts/install-skill.mjs`) for tests and
|
|
259
|
+
// for anyone repairing an install by hand. Nothing invokes it automatically —
|
|
260
|
+
// see cli/README.md for why this is no longer a postinstall hook.
|
|
261
|
+
if (
|
|
262
|
+
process.argv[1] &&
|
|
263
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
264
|
+
) {
|
|
265
|
+
const result = await installSkill();
|
|
266
|
+
if (result.reason === "opt-out") {
|
|
267
|
+
console.log("Skipped Weft Skill installation (WEFT_SKIP_SKILL_INSTALL=1).");
|
|
268
|
+
} else if (result.status === "ok") {
|
|
269
|
+
console.log(
|
|
270
|
+
`Installed the Weft Skill for ${result.installed} detected agent host(s).`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
6
|
+
|
|
7
|
+
/** Files that make up the `weft` Skill, relative to the skill directory. */
|
|
8
|
+
export const skillFiles = ["SKILL.md", "rules/cli.md"];
|
|
9
|
+
|
|
10
|
+
/** [detection root, skill directory] pairs, both relative to `home`. */
|
|
11
|
+
export const targets = [
|
|
12
|
+
[".agents", ".agents/skills/weft"],
|
|
13
|
+
[".claude", ".claude/skills/weft"],
|
|
14
|
+
[".cursor", ".cursor/skills/weft"],
|
|
15
|
+
[".cline", ".cline/skills/weft"],
|
|
16
|
+
[".config/opencode", ".config/opencode/skills/weft"],
|
|
17
|
+
[".openclaw", ".openclaw/skills/weft"],
|
|
18
|
+
[".hermes", ".hermes/skills/weft"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export async function isDetectedHost(relativeRoot) {
|
|
22
|
+
try {
|
|
23
|
+
return (await lstat(join(home, relativeRoot))).isDirectory();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") return false;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function containsSymlink(relativeTarget) {
|
|
31
|
+
let current = home;
|
|
32
|
+
for (const part of relativeTarget.split("/")) {
|
|
33
|
+
current = join(current, part);
|
|
34
|
+
try {
|
|
35
|
+
if ((await lstat(current)).isSymbolicLink()) return true;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "ENOENT") return false;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|