@devdogfish/jawfish 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 +93 -0
- package/dist/config.js +72 -0
- package/dist/errors.js +9 -0
- package/dist/main.js +1011 -0
- package/dist/tool-adapters.js +41 -0
- package/jawfish-logo-dark.png +0 -0
- package/jawfish-logo-light.png +0 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="./jawfish-logo-dark.png">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="./jawfish-logo-light.png">
|
|
5
|
+
<img alt="Jawfish - A minimal package manager for your AI skills." src="./jawfish-logo-light.png" height="320" style="margin-bottom: 20px;">
|
|
6
|
+
</picture>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
## What Is Jawfish?
|
|
10
|
+
|
|
11
|
+
Jawfish is a small package manager for reusable agentics:
|
|
12
|
+
|
|
13
|
+
1. Keep skills, prompts, and agents in one content library.
|
|
14
|
+
2. Install them globally or into a project.
|
|
15
|
+
3. Update them when upstream changes.
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
Install:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun install --global @devdogfish/jawfish
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Configure:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"allowedTools": ["codex"],
|
|
30
|
+
"defaultTool": "codex",
|
|
31
|
+
"contentLibrary": "git@github.com:you/agentics.git"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Save it at `~/.jawfish/config.json`.
|
|
36
|
+
|
|
37
|
+
Add a skill:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
jawfish add handoff
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Install everything from the manifest:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
jawfish install
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Update later:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
jawfish update
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How It Works
|
|
56
|
+
|
|
57
|
+
Jawfish reads from one content library and writes tool-native files into the
|
|
58
|
+
current project or your global tool config.
|
|
59
|
+
|
|
60
|
+
Project installs are tracked in `jawfish.json`. Global installs are tracked in
|
|
61
|
+
`~/.jawfish/jawfish.json`.
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
|
|
65
|
+
| Command | What it does |
|
|
66
|
+
| ----------------------- | ------------------------------------ |
|
|
67
|
+
| `jawfish add <name>` | Install from your library |
|
|
68
|
+
| `jawfish add <source>` | Import from a URL or local file |
|
|
69
|
+
| `jawfish install` | Reinstall everything in the manifest |
|
|
70
|
+
| `jawfish update [name]` | Pull upstream changes |
|
|
71
|
+
| `jawfish remove <name>` | Remove a managed install |
|
|
72
|
+
|
|
73
|
+
Add `--global` to target your global tool config instead of the current
|
|
74
|
+
project.
|
|
75
|
+
|
|
76
|
+
Jawfish currently supports `codex`, `claude-code`, and `hermes`.
|
|
77
|
+
|
|
78
|
+
Project installs go into `.codex/`, `.claude/`, or `.hermes/`. Global Codex
|
|
79
|
+
installs go into:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
~/.codex/skills
|
|
83
|
+
~/.codex/agents
|
|
84
|
+
~/.codex/prompts
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Develop
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
bun install
|
|
91
|
+
bun run typecheck
|
|
92
|
+
bun run test
|
|
93
|
+
```
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { cancel, isCancel, select } from "@clack/prompts";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { errorHasCode, errorMessage } from "./errors.js";
|
|
6
|
+
import { supportedTools } from "./tool-adapters.js";
|
|
7
|
+
export const defaultAllowedTools = supportedTools;
|
|
8
|
+
export function jawfishHome(env = process.env) {
|
|
9
|
+
return env.JAWFISH_HOME ?? join(homedir(), ".jawfish");
|
|
10
|
+
}
|
|
11
|
+
export function configPath(home = jawfishHome()) {
|
|
12
|
+
return join(home, "config.json");
|
|
13
|
+
}
|
|
14
|
+
export async function loadConfig(options = {}) {
|
|
15
|
+
const env = options.env ?? process.env;
|
|
16
|
+
const home = jawfishHome(env);
|
|
17
|
+
const filePath = configPath(home);
|
|
18
|
+
const existing = await readConfig(filePath);
|
|
19
|
+
const config = {
|
|
20
|
+
...existing,
|
|
21
|
+
allowedTools: existing.allowedTools ?? [...defaultAllowedTools],
|
|
22
|
+
};
|
|
23
|
+
let changed = existing.allowedTools === undefined;
|
|
24
|
+
if (config.contentLibrary === undefined && env.JAWFISH_CONTENT_LIBRARY) {
|
|
25
|
+
config.contentLibrary = env.JAWFISH_CONTENT_LIBRARY;
|
|
26
|
+
changed = true;
|
|
27
|
+
}
|
|
28
|
+
if (config.defaultTool === undefined) {
|
|
29
|
+
config.defaultTool = await chooseDefaultTool(config.allowedTools, options, env);
|
|
30
|
+
changed = true;
|
|
31
|
+
}
|
|
32
|
+
if (changed) {
|
|
33
|
+
await writeConfig(filePath, config);
|
|
34
|
+
}
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
export async function promptForTool(allowedTools) {
|
|
38
|
+
const selected = await select({
|
|
39
|
+
message: "Select default tool",
|
|
40
|
+
options: allowedTools.map((tool) => ({ label: tool, value: tool })),
|
|
41
|
+
});
|
|
42
|
+
if (isCancel(selected)) {
|
|
43
|
+
cancel("No tool selected");
|
|
44
|
+
throw new Error("No tool selected");
|
|
45
|
+
}
|
|
46
|
+
return selected;
|
|
47
|
+
}
|
|
48
|
+
async function chooseDefaultTool(allowedTools, options, env) {
|
|
49
|
+
const envDefault = env.JAWFISH_DEFAULT_TOOL;
|
|
50
|
+
if (envDefault !== undefined) {
|
|
51
|
+
if (!allowedTools.includes(envDefault)) {
|
|
52
|
+
throw new Error(`Default tool is not allowed: ${envDefault}`);
|
|
53
|
+
}
|
|
54
|
+
return envDefault;
|
|
55
|
+
}
|
|
56
|
+
return (options.promptForDefaultTool ?? promptForTool)(allowedTools);
|
|
57
|
+
}
|
|
58
|
+
async function readConfig(path) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (errorHasCode(error, "ENOENT")) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Invalid config at ${path}: ${errorMessage(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function writeConfig(path, config) {
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
72
|
+
}
|
package/dist/errors.js
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
+
import { cancel, isCancel, select } from "@clack/prompts";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { assertSupportedTool, destinationPath, supportedTools, typeFolder, } from "./tool-adapters.js";
|
|
9
|
+
const version = "0.1.0";
|
|
10
|
+
const catalogFile = "catalog.json";
|
|
11
|
+
const indexCatalogFile = "index.json";
|
|
12
|
+
const projectManifestFile = "jawfish.json";
|
|
13
|
+
const managedMarkerFile = ".jawfish-managed.json";
|
|
14
|
+
const libraryIgnoreEntries = ["config.json", "jawfish.json"];
|
|
15
|
+
const defaultTools = supportedTools;
|
|
16
|
+
const agenticTypes = [
|
|
17
|
+
"skill",
|
|
18
|
+
"agent",
|
|
19
|
+
"prompt",
|
|
20
|
+
];
|
|
21
|
+
const commandSpecs = {
|
|
22
|
+
add: {
|
|
23
|
+
description: "Install an agentic from the library, or import a URL/local path.",
|
|
24
|
+
summary: "Install or import an agentic",
|
|
25
|
+
usage: "jawfish add [options] <name|source>",
|
|
26
|
+
options: [
|
|
27
|
+
"-g, --global Install globally",
|
|
28
|
+
"--name <name> Override imported package name",
|
|
29
|
+
"-h, --help Show help",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
install: {
|
|
33
|
+
description: "Materialize manifest jawfish into tool-native directories.",
|
|
34
|
+
summary: "Materialize manifest jawfish",
|
|
35
|
+
usage: "jawfish install [options]",
|
|
36
|
+
options: [
|
|
37
|
+
"-g, --global Install global manifest",
|
|
38
|
+
"-h, --help Show help",
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
update: {
|
|
42
|
+
description: "Refresh one or all upstream-backed jawfish.",
|
|
43
|
+
summary: "Update upstream-backed jawfish",
|
|
44
|
+
usage: "jawfish update [options] [name]",
|
|
45
|
+
options: [
|
|
46
|
+
"-g, --global Reinstall global manifest if already installed",
|
|
47
|
+
"-F, --force Replace dirty package contents",
|
|
48
|
+
"-h, --help Show help",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
remove: {
|
|
52
|
+
description: "Remove installed managed jawfish.",
|
|
53
|
+
summary: "Remove installed jawfish",
|
|
54
|
+
usage: "jawfish remove [options] <name>",
|
|
55
|
+
options: [
|
|
56
|
+
"-g, --global Remove global install",
|
|
57
|
+
"-h, --help Show help",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const commandNames = Object.keys(commandSpecs);
|
|
62
|
+
export async function promptForTool(allowedTools) {
|
|
63
|
+
const selected = await select({
|
|
64
|
+
message: "Select default tool",
|
|
65
|
+
options: allowedTools.map((tool) => ({ label: tool, value: tool })),
|
|
66
|
+
});
|
|
67
|
+
if (isCancel(selected)) {
|
|
68
|
+
cancel("No tool selected");
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
return selected;
|
|
73
|
+
}
|
|
74
|
+
export async function promptForAgenticType(packagePath) {
|
|
75
|
+
const selected = await select({
|
|
76
|
+
message: `Select agentic type for ${packagePath}`,
|
|
77
|
+
options: agenticTypes.map((type) => ({ label: type, value: type })),
|
|
78
|
+
});
|
|
79
|
+
if (isCancel(selected)) {
|
|
80
|
+
cancel("No agentic type selected");
|
|
81
|
+
throw new Error("No agentic type selected");
|
|
82
|
+
}
|
|
83
|
+
return selected;
|
|
84
|
+
}
|
|
85
|
+
export async function run(argv) {
|
|
86
|
+
const [command, ...args] = argv;
|
|
87
|
+
try {
|
|
88
|
+
if (command === undefined || isHelp(command)) {
|
|
89
|
+
printRootHelp();
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
if (command === "--version" || command === "-v") {
|
|
93
|
+
console.log(version);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
if (!isCommandName(command)) {
|
|
97
|
+
console.error(`Unknown command: ${command}`);
|
|
98
|
+
console.error("Run jawfish --help for usage.");
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const parsed = parseArgs(args);
|
|
102
|
+
if (parsed.help) {
|
|
103
|
+
printCommandHelp(command);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
switch (command) {
|
|
107
|
+
case "add":
|
|
108
|
+
return await addCommand(parsed);
|
|
109
|
+
case "install":
|
|
110
|
+
return await installCommand(parsed);
|
|
111
|
+
case "remove":
|
|
112
|
+
return await removeCommand(parsed);
|
|
113
|
+
case "update":
|
|
114
|
+
return await updateCommand(parsed);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function addCommand(args) {
|
|
123
|
+
const source = args.positionals[0];
|
|
124
|
+
if (source === undefined) {
|
|
125
|
+
console.error("Usage: jawfish add [options] <name|source>");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const config = await loadConfig();
|
|
129
|
+
const libraryDir = await resolveContentLibrary(config);
|
|
130
|
+
const catalog = await readCatalog(libraryDir);
|
|
131
|
+
const scope = getScope(args);
|
|
132
|
+
if (catalogHasAgentic(catalog, source)) {
|
|
133
|
+
const tool = await installOne(libraryDir, catalog, source, scope, config);
|
|
134
|
+
console.log(`Added ${source} to ${scope}`);
|
|
135
|
+
printCatalogEntry(source, catalog.jawfish[source], tool);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const imported = await importPackage(libraryDir, catalog, source, args.name);
|
|
139
|
+
await writeCatalog(libraryDir, catalog);
|
|
140
|
+
if (!(await pushLibraryChanges(libraryDir, `add ${imported}`))) {
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
await installOne(libraryDir, catalog, imported, scope, config);
|
|
144
|
+
console.log(`Added ${imported} to ${scope}`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
async function installCommand(args) {
|
|
148
|
+
const config = await loadConfig();
|
|
149
|
+
const libraryDir = await resolveContentLibrary(config);
|
|
150
|
+
await syncLibrary(libraryDir);
|
|
151
|
+
const catalog = await readCatalog(libraryDir);
|
|
152
|
+
const scope = getScope(args);
|
|
153
|
+
const manifest = await readManifest(scope);
|
|
154
|
+
const names = Object.keys(manifest.jawfish);
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
const tool = manifest.jawfish[name].tool;
|
|
157
|
+
assertConfiguredTool(config, tool);
|
|
158
|
+
await materialize(libraryDir, catalog, name, scope, tool);
|
|
159
|
+
}
|
|
160
|
+
console.log(`Installed ${names.length} jawfish to ${scope}`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
async function removeCommand(args) {
|
|
164
|
+
const name = args.positionals[0];
|
|
165
|
+
if (name === undefined) {
|
|
166
|
+
console.error("Usage: jawfish remove [options] <name>");
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const config = await loadConfig();
|
|
170
|
+
const libraryDir = await resolveContentLibrary(config);
|
|
171
|
+
const catalog = await readCatalog(libraryDir);
|
|
172
|
+
const scope = getScope(args);
|
|
173
|
+
const manifest = await readManifest(scope);
|
|
174
|
+
const manifestEntry = manifest.jawfish[name];
|
|
175
|
+
const catalogEntry = catalog.jawfish[name];
|
|
176
|
+
if (manifestEntry !== undefined && catalogEntry !== undefined) {
|
|
177
|
+
assertConfiguredTool(config, manifestEntry.tool);
|
|
178
|
+
await removeMaterialized(name, catalogEntry.type, scope, manifestEntry.tool);
|
|
179
|
+
}
|
|
180
|
+
delete manifest.jawfish[name];
|
|
181
|
+
await writeManifest(scope, manifest);
|
|
182
|
+
console.log(`Removed ${name} from ${scope}`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
async function updateCommand(args) {
|
|
186
|
+
const config = await loadConfig();
|
|
187
|
+
const libraryDir = await resolveContentLibrary(config);
|
|
188
|
+
const catalog = await readCatalog(libraryDir);
|
|
189
|
+
const name = args.positionals[0];
|
|
190
|
+
const reinstallScope = getScope(args);
|
|
191
|
+
if (name !== undefined) {
|
|
192
|
+
await updatePackage(libraryDir, catalog, name, args.force);
|
|
193
|
+
await writeCatalog(libraryDir, catalog);
|
|
194
|
+
if (!(await pushLibraryChanges(libraryDir, `update ${name}`))) {
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
await reinstallInScopeIfPresent(libraryDir, catalog, name, reinstallScope, config);
|
|
198
|
+
console.log(`Updated ${name}`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const summary = await updateAllPackages(libraryDir, catalog, args.force);
|
|
202
|
+
if (summary.failed.length === 0 && summary.updated.length > 0) {
|
|
203
|
+
await writeCatalog(libraryDir, catalog);
|
|
204
|
+
if (!(await pushLibraryChanges(libraryDir, "update jawfish"))) {
|
|
205
|
+
printBulkUpdateSummary(summary);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
await Promise.all(summary.updated.map((updatedName) => reinstallInScopeIfPresent(libraryDir, catalog, updatedName, reinstallScope, config)));
|
|
209
|
+
}
|
|
210
|
+
printBulkUpdateSummary(summary);
|
|
211
|
+
return summary.failed.length > 0 ? 1 : 0;
|
|
212
|
+
}
|
|
213
|
+
async function installOne(libraryDir, catalog, name, scope, config) {
|
|
214
|
+
const tool = await resolveTool(config);
|
|
215
|
+
await materialize(libraryDir, catalog, name, scope, tool);
|
|
216
|
+
const manifest = await readManifest(scope);
|
|
217
|
+
manifest.jawfish[name] = { tool };
|
|
218
|
+
await writeManifest(scope, manifest);
|
|
219
|
+
return tool;
|
|
220
|
+
}
|
|
221
|
+
async function materialize(libraryDir, catalog, name, scope, tool) {
|
|
222
|
+
const entry = catalog.jawfish[name];
|
|
223
|
+
if (entry === undefined) {
|
|
224
|
+
throw new Error(`Unknown agentic: ${name}`);
|
|
225
|
+
}
|
|
226
|
+
const sourcePath = resolveInside(libraryDir, entry.path);
|
|
227
|
+
const destination = destinationPath(name, entry.type, scope, tool, toolPaths());
|
|
228
|
+
const managedFiles = await managedFileSet(destination);
|
|
229
|
+
const sourceFiles = await packageFiles(sourcePath);
|
|
230
|
+
await assertNoUnmanagedConflicts(destination, sourceFiles, managedFiles);
|
|
231
|
+
await mkdir(destination, { recursive: true });
|
|
232
|
+
await removeStaleManagedFiles(destination, sourceFiles, managedFiles);
|
|
233
|
+
await copyPackageFiles(destination, sourceFiles);
|
|
234
|
+
await writeJson(join(destination, managedMarkerFile), {
|
|
235
|
+
files: sourceFiles.map((file) => file.relativePath).sort(),
|
|
236
|
+
name,
|
|
237
|
+
tool,
|
|
238
|
+
type: entry.type,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function assertNoUnmanagedConflicts(destination, sourceFiles, managedFiles) {
|
|
242
|
+
for (const sourceFile of sourceFiles) {
|
|
243
|
+
const installedPath = join(destination, sourceFile.relativePath);
|
|
244
|
+
if ((await exists(installedPath)) &&
|
|
245
|
+
!managedFiles.has(sourceFile.relativePath)) {
|
|
246
|
+
throw new Error(`Refusing to overwrite unmanaged destination file: ${installedPath}\n` +
|
|
247
|
+
"Remove it or move it aside, then retry.");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function removeStaleManagedFiles(destination, sourceFiles, managedFiles) {
|
|
252
|
+
const sourceFileNames = new Set(sourceFiles.map((file) => file.relativePath));
|
|
253
|
+
for (const managedFile of managedFiles) {
|
|
254
|
+
if (!sourceFileNames.has(managedFile)) {
|
|
255
|
+
const installedPath = join(destination, managedFile);
|
|
256
|
+
await rm(installedPath, { force: true });
|
|
257
|
+
await removeEmptyParents(dirname(installedPath), destination);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function copyPackageFiles(destination, sourceFiles) {
|
|
262
|
+
for (const sourceFile of sourceFiles) {
|
|
263
|
+
const installedPath = join(destination, sourceFile.relativePath);
|
|
264
|
+
await mkdir(dirname(installedPath), { recursive: true });
|
|
265
|
+
await cp(sourceFile.path, installedPath);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function removeMaterialized(name, type, scope, tool) {
|
|
269
|
+
const destination = destinationPath(name, type, scope, tool, toolPaths());
|
|
270
|
+
await removeManagedDestination(destination);
|
|
271
|
+
}
|
|
272
|
+
async function managedFileSet(destination) {
|
|
273
|
+
if (!(await exists(destination))) {
|
|
274
|
+
return new Set();
|
|
275
|
+
}
|
|
276
|
+
const markerPath = join(destination, managedMarkerFile);
|
|
277
|
+
if (!(await exists(markerPath))) {
|
|
278
|
+
throw new Error(`Refusing to overwrite unmanaged destination: ${destination}\n` +
|
|
279
|
+
"Remove it or move it aside, then retry.");
|
|
280
|
+
}
|
|
281
|
+
const marker = JSON.parse(await readFile(markerPath, "utf8"));
|
|
282
|
+
if (Array.isArray(marker.files)) {
|
|
283
|
+
return new Set(marker.files);
|
|
284
|
+
}
|
|
285
|
+
return new Set(await installedFiles(destination));
|
|
286
|
+
}
|
|
287
|
+
async function removeManagedDestination(destination) {
|
|
288
|
+
if (!(await exists(destination))) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const markerPath = join(destination, managedMarkerFile);
|
|
292
|
+
if (!(await exists(markerPath))) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
for (const managedFile of await managedFileSet(destination)) {
|
|
296
|
+
const installedPath = join(destination, managedFile);
|
|
297
|
+
await rm(installedPath, { force: true });
|
|
298
|
+
await removeEmptyParents(dirname(installedPath), destination);
|
|
299
|
+
}
|
|
300
|
+
await rm(markerPath, { force: true });
|
|
301
|
+
await removeEmptyParents(destination, destination);
|
|
302
|
+
}
|
|
303
|
+
async function packageFiles(sourcePath) {
|
|
304
|
+
const sourceStat = await stat(sourcePath);
|
|
305
|
+
if (!sourceStat.isDirectory()) {
|
|
306
|
+
return [{ path: sourcePath, relativePath: basename(sourcePath) }];
|
|
307
|
+
}
|
|
308
|
+
return directoryFiles(sourcePath, sourcePath);
|
|
309
|
+
}
|
|
310
|
+
async function installedFiles(destination) {
|
|
311
|
+
return (await directoryFiles(destination, destination))
|
|
312
|
+
.map((file) => file.relativePath)
|
|
313
|
+
.filter((file) => file !== managedMarkerFile);
|
|
314
|
+
}
|
|
315
|
+
async function directoryFiles(root, current) {
|
|
316
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
317
|
+
const files = [];
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
const path = join(current, entry.name);
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
files.push(...(await directoryFiles(root, path)));
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (entry.isFile()) {
|
|
325
|
+
files.push({
|
|
326
|
+
path,
|
|
327
|
+
relativePath: relative(root, path),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return files;
|
|
332
|
+
}
|
|
333
|
+
async function removeEmptyParents(start, root) {
|
|
334
|
+
const resolvedRoot = resolve(root);
|
|
335
|
+
let current = resolve(start);
|
|
336
|
+
while (current === resolvedRoot || current.startsWith(`${resolvedRoot}/`)) {
|
|
337
|
+
if (!(await exists(current))) {
|
|
338
|
+
current = dirname(current);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if ((await readdir(current)).length > 0) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
await rm(current, { force: true, recursive: true });
|
|
345
|
+
if (current === resolvedRoot) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
current = dirname(current);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function importPackage(libraryDir, catalog, source, nameOverride) {
|
|
352
|
+
const acquired = await acquireSource(source);
|
|
353
|
+
const name = nameOverride ?? acquired.inferredName;
|
|
354
|
+
if (catalogHasAgentic(catalog, name)) {
|
|
355
|
+
throw new Error(`Agentic already exists in catalog: ${name}`);
|
|
356
|
+
}
|
|
357
|
+
const type = await inferType(acquired.packagePath, acquired.entryFile);
|
|
358
|
+
const packagePath = join(typeFolder(type), name);
|
|
359
|
+
const destination = resolveInside(libraryDir, packagePath);
|
|
360
|
+
await rm(destination, { force: true, recursive: true });
|
|
361
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
362
|
+
await cp(acquired.packagePath, destination, { recursive: true });
|
|
363
|
+
catalog.jawfish[name] = {
|
|
364
|
+
description: "",
|
|
365
|
+
path: packagePath,
|
|
366
|
+
type,
|
|
367
|
+
upstream: source,
|
|
368
|
+
};
|
|
369
|
+
return name;
|
|
370
|
+
}
|
|
371
|
+
async function acquireSource(source) {
|
|
372
|
+
return isUrl(source) ? acquireUrlSource(source) : acquireLocalSource(source);
|
|
373
|
+
}
|
|
374
|
+
async function acquireLocalSource(source) {
|
|
375
|
+
const localPath = resolve(process.cwd(), source);
|
|
376
|
+
const sourceStat = await stat(localPath);
|
|
377
|
+
const packagePath = sourceStat.isDirectory() ? localPath : dirname(localPath);
|
|
378
|
+
return {
|
|
379
|
+
entryFile: sourceStat.isDirectory() ? undefined : localPath,
|
|
380
|
+
inferredName: inferPackageName(packagePath),
|
|
381
|
+
packagePath,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
async function acquireUrlSource(source) {
|
|
385
|
+
const tempDir = await mkdtemp(join(tmpdir(), "jawfish-source-"));
|
|
386
|
+
const url = new URL(source);
|
|
387
|
+
const fileName = basename(url.pathname) || "agentic.md";
|
|
388
|
+
const sourceResponse = await fetchUrl(source);
|
|
389
|
+
if (isDirectoryListing(sourceResponse)) {
|
|
390
|
+
await downloadUrlDirectory(source, tempDir, sourceResponse.links);
|
|
391
|
+
return {
|
|
392
|
+
inferredName: inferUrlPackageName(url.pathname),
|
|
393
|
+
packagePath: tempDir,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const parentUrl = new URL(".", source).toString();
|
|
397
|
+
const parentResponse = await fetchUrl(parentUrl, false);
|
|
398
|
+
const filePath = join(tempDir, fileName);
|
|
399
|
+
if (parentResponse !== undefined && isDirectoryListing(parentResponse)) {
|
|
400
|
+
await downloadUrlDirectory(parentUrl, tempDir, parentResponse.links);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
await writeFile(filePath, sourceResponse.body);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
entryFile: filePath,
|
|
407
|
+
inferredName: inferUrlPackageName(dirname(url.pathname)) || inferPackageName(fileName),
|
|
408
|
+
packagePath: tempDir,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async function fetchUrl(source, throwOnMissing = true) {
|
|
412
|
+
const response = await fetch(source);
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
if (!throwOnMissing && response.status === 404) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`Failed to fetch ${source}: ${response.status} ${response.statusText}`);
|
|
418
|
+
}
|
|
419
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
420
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
421
|
+
return {
|
|
422
|
+
body,
|
|
423
|
+
contentType,
|
|
424
|
+
links: parseHtmlLinks(body.toString("utf8")),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function isDirectoryListing(response) {
|
|
428
|
+
return (response.contentType.includes("text/html") && response.links.length > 0);
|
|
429
|
+
}
|
|
430
|
+
async function downloadUrlDirectory(source, destination, directoryLinks) {
|
|
431
|
+
const directoryUrl = source.endsWith("/") ? source : `${source}/`;
|
|
432
|
+
const links = directoryLinks ?? (await fetchUrl(directoryUrl)).links;
|
|
433
|
+
await mkdir(destination, { recursive: true });
|
|
434
|
+
for (const link of links) {
|
|
435
|
+
const childUrl = new URL(link, directoryUrl);
|
|
436
|
+
if (!isImportableChildUrl(childUrl, directoryUrl)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const childName = basename(childUrl.pathname.replace(/\/$/u, ""));
|
|
440
|
+
if (childName === "") {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const childResponse = await fetchUrl(childUrl.toString());
|
|
444
|
+
const childDestination = join(destination, childName);
|
|
445
|
+
if (isDirectoryListing(childResponse)) {
|
|
446
|
+
await downloadUrlDirectory(childUrl.toString(), childDestination, childResponse.links);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
await writeFile(childDestination, childResponse.body);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function parseHtmlLinks(html) {
|
|
453
|
+
return [...html.matchAll(/href\s*=\s*["']([^"']+)["']/giu)]
|
|
454
|
+
.map((match) => match[1])
|
|
455
|
+
.filter((href) => href !== "" && !href.startsWith("#") && !href.startsWith("?"));
|
|
456
|
+
}
|
|
457
|
+
function isAgenticType(value) {
|
|
458
|
+
return agenticTypes.includes(value);
|
|
459
|
+
}
|
|
460
|
+
function isImportableChildUrl(childUrl, parentUrl) {
|
|
461
|
+
const parent = new URL(parentUrl);
|
|
462
|
+
return (childUrl.origin === parent.origin &&
|
|
463
|
+
childUrl.pathname !== parent.pathname &&
|
|
464
|
+
childUrl.pathname.startsWith(parent.pathname) &&
|
|
465
|
+
!childUrl.pathname.includes(".."));
|
|
466
|
+
}
|
|
467
|
+
function inferUrlPackageName(pathname) {
|
|
468
|
+
return basename(pathname.replace(/\/$/u, ""));
|
|
469
|
+
}
|
|
470
|
+
async function updatePackage(libraryDir, catalog, name, force) {
|
|
471
|
+
const entry = catalog.jawfish[name];
|
|
472
|
+
if (entry === undefined) {
|
|
473
|
+
throw new Error(`Unknown agentic: ${name}`);
|
|
474
|
+
}
|
|
475
|
+
if (entry.upstream === undefined) {
|
|
476
|
+
throw new Error(`Agentic has no upstream: ${name}`);
|
|
477
|
+
}
|
|
478
|
+
const dirty = await dirtyPaths(libraryDir, entry.path);
|
|
479
|
+
if (dirty.length > 0 && !force) {
|
|
480
|
+
throw new Error(`Package has dirty local changes: ${name}\n` +
|
|
481
|
+
dirty.map((path) => ` ${path}`).join("\n") +
|
|
482
|
+
"\nRun jawfish update --force " +
|
|
483
|
+
name +
|
|
484
|
+
" to replace them.");
|
|
485
|
+
}
|
|
486
|
+
const acquired = await acquireSource(entry.upstream);
|
|
487
|
+
const destination = resolveInside(libraryDir, entry.path);
|
|
488
|
+
await rm(destination, { force: true, recursive: true });
|
|
489
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
490
|
+
await cp(acquired.packagePath, destination, { recursive: true });
|
|
491
|
+
}
|
|
492
|
+
async function updateAllPackages(libraryDir, catalog, force) {
|
|
493
|
+
const summary = { failed: [], skipped: [], updated: [] };
|
|
494
|
+
for (const name of Object.keys(catalog.jawfish)) {
|
|
495
|
+
const entry = catalog.jawfish[name];
|
|
496
|
+
if (entry.upstream === undefined) {
|
|
497
|
+
summary.skipped.push(name);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
await updatePackage(libraryDir, catalog, name, force);
|
|
502
|
+
summary.updated.push(name);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
const failure = bulkUpdateFailure(name, error);
|
|
506
|
+
summary.failed.push(failure);
|
|
507
|
+
console.error(`Failed to update ${name}:\n${failure.details}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return summary;
|
|
511
|
+
}
|
|
512
|
+
function printBulkUpdateSummary(summary) {
|
|
513
|
+
console.log(`Updated: ${formatSummaryNames(summary.updated)}`);
|
|
514
|
+
console.log(`Skipped: ${formatSummaryNames(summary.skipped)}`);
|
|
515
|
+
console.log(`Failed: ${formatBulkUpdateFailures(summary.failed)}`);
|
|
516
|
+
}
|
|
517
|
+
function formatSummaryNames(names) {
|
|
518
|
+
return names.length === 0 ? "none" : names.join(", ");
|
|
519
|
+
}
|
|
520
|
+
function formatBulkUpdateFailures(failures) {
|
|
521
|
+
if (failures.length === 0) {
|
|
522
|
+
return "none";
|
|
523
|
+
}
|
|
524
|
+
return failures
|
|
525
|
+
.map((failure) => `${failure.name} (${failure.message})`)
|
|
526
|
+
.join(", ");
|
|
527
|
+
}
|
|
528
|
+
function bulkUpdateFailure(name, error) {
|
|
529
|
+
const details = stringifyError(error);
|
|
530
|
+
return {
|
|
531
|
+
details,
|
|
532
|
+
message: details.split("\n")[0],
|
|
533
|
+
name,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function stringifyError(error) {
|
|
537
|
+
return error instanceof Error ? error.message : String(error);
|
|
538
|
+
}
|
|
539
|
+
async function reinstallInScopeIfPresent(libraryDir, catalog, name, scope, config) {
|
|
540
|
+
const manifest = await readManifest(scope);
|
|
541
|
+
const entry = manifest.jawfish[name];
|
|
542
|
+
if (entry !== undefined) {
|
|
543
|
+
assertConfiguredTool(config, entry.tool);
|
|
544
|
+
await materialize(libraryDir, catalog, name, scope, entry.tool);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function resolveTool(config) {
|
|
548
|
+
const allowedTools = config.allowedTools.length > 0 ? config.allowedTools : [...defaultTools];
|
|
549
|
+
config.allowedTools = allowedTools;
|
|
550
|
+
if (config.defaultTool !== undefined) {
|
|
551
|
+
assertConfiguredTool(config, config.defaultTool);
|
|
552
|
+
return config.defaultTool;
|
|
553
|
+
}
|
|
554
|
+
const selected = await promptForTool(allowedTools);
|
|
555
|
+
if (selected === "") {
|
|
556
|
+
throw new Error("No default tool selected");
|
|
557
|
+
}
|
|
558
|
+
assertConfiguredTool(config, selected);
|
|
559
|
+
config.defaultTool = selected;
|
|
560
|
+
await writeConfig(config);
|
|
561
|
+
return selected;
|
|
562
|
+
}
|
|
563
|
+
function assertConfiguredTool(config, tool) {
|
|
564
|
+
if (!config.allowedTools.includes(tool)) {
|
|
565
|
+
throw new Error(`Tool is not configured: ${tool}. Configured tools: ${config.allowedTools.join(", ")}`);
|
|
566
|
+
}
|
|
567
|
+
assertSupportedTool(tool);
|
|
568
|
+
}
|
|
569
|
+
async function loadConfig() {
|
|
570
|
+
const path = await existingConfigPath();
|
|
571
|
+
const parsed = path === undefined
|
|
572
|
+
? {}
|
|
573
|
+
: JSON.parse(await readFile(path, "utf8"));
|
|
574
|
+
const config = {
|
|
575
|
+
allowedTools: parsed.allowedTools ?? [...defaultTools],
|
|
576
|
+
contentLibrary: parsed.contentLibrary ?? process.env.JAWFISH_CONTENT_LIBRARY,
|
|
577
|
+
defaultTool: parsed.defaultTool,
|
|
578
|
+
};
|
|
579
|
+
let changed = path === undefined ||
|
|
580
|
+
parsed.allowedTools === undefined ||
|
|
581
|
+
(parsed.contentLibrary === undefined &&
|
|
582
|
+
process.env.JAWFISH_CONTENT_LIBRARY !== undefined);
|
|
583
|
+
const envDefaultTool = process.env.JAWFISH_DEFAULT_TOOL;
|
|
584
|
+
if (config.defaultTool === undefined && envDefaultTool !== undefined) {
|
|
585
|
+
assertConfiguredTool(config, envDefaultTool);
|
|
586
|
+
config.defaultTool = envDefaultTool;
|
|
587
|
+
changed = true;
|
|
588
|
+
}
|
|
589
|
+
if (changed) {
|
|
590
|
+
await writeConfig(config);
|
|
591
|
+
}
|
|
592
|
+
return config;
|
|
593
|
+
}
|
|
594
|
+
async function writeConfig(config) {
|
|
595
|
+
await writeJson(configPath(), config);
|
|
596
|
+
}
|
|
597
|
+
async function resolveContentLibrary(config) {
|
|
598
|
+
if (config.contentLibrary === undefined || config.contentLibrary === "") {
|
|
599
|
+
throw new Error(`Missing contentLibrary in ${configPath()}\n` +
|
|
600
|
+
"Set it to your jawfish content library path or clone URL.");
|
|
601
|
+
}
|
|
602
|
+
const configured = isAbsolute(config.contentLibrary)
|
|
603
|
+
? config.contentLibrary
|
|
604
|
+
: resolve(process.cwd(), config.contentLibrary);
|
|
605
|
+
if (resolve(configured) === resolve(deprecatedLibraryPath())) {
|
|
606
|
+
throw new Error(`Nested content library is no longer supported: ${configured}\n` +
|
|
607
|
+
`Move the library to ${managedLibraryPath()} and update ${configPath()}.`);
|
|
608
|
+
}
|
|
609
|
+
if ((await exists(configured)) && !(await isBareRepository(configured))) {
|
|
610
|
+
await ensureLibraryIgnore(configured);
|
|
611
|
+
return configured;
|
|
612
|
+
}
|
|
613
|
+
const libraryDir = managedLibraryPath();
|
|
614
|
+
if (await exists(join(libraryDir, ".git"))) {
|
|
615
|
+
await ensureLibraryIgnore(libraryDir);
|
|
616
|
+
return libraryDir;
|
|
617
|
+
}
|
|
618
|
+
await mkdir(jawfishHome(), { recursive: true });
|
|
619
|
+
await initializeManagedLibrary(config.contentLibrary, libraryDir);
|
|
620
|
+
await ensureLibraryIgnore(libraryDir);
|
|
621
|
+
return libraryDir;
|
|
622
|
+
}
|
|
623
|
+
async function initializeManagedLibrary(source, libraryDir) {
|
|
624
|
+
await runCommand("git", ["init"], libraryDir);
|
|
625
|
+
await runCommand("git", ["remote", "add", "origin", source], libraryDir);
|
|
626
|
+
await runCommand("git", ["fetch", "origin"], libraryDir);
|
|
627
|
+
const branch = await remoteDefaultBranch(libraryDir);
|
|
628
|
+
await runCommand("git", ["checkout", "-B", branch, `origin/${branch}`], libraryDir);
|
|
629
|
+
await runCommand("git", ["branch", "--set-upstream-to", `origin/${branch}`, branch], libraryDir);
|
|
630
|
+
}
|
|
631
|
+
async function remoteDefaultBranch(libraryDir) {
|
|
632
|
+
const result = await runCommand("git", ["ls-remote", "--symref", "origin", "HEAD"], libraryDir);
|
|
633
|
+
const match = /^ref: refs\/heads\/([^\t]+)\tHEAD$/mu.exec(result.stdout);
|
|
634
|
+
if (match === null) {
|
|
635
|
+
throw new Error("Could not determine content library default branch");
|
|
636
|
+
}
|
|
637
|
+
return match[1];
|
|
638
|
+
}
|
|
639
|
+
async function readCatalog(libraryDir) {
|
|
640
|
+
const indexPath = join(libraryDir, indexCatalogFile);
|
|
641
|
+
if (await exists(indexPath)) {
|
|
642
|
+
const parsed = JSON.parse(await readFile(indexPath, "utf8"));
|
|
643
|
+
if (typeof parsed !== "object" ||
|
|
644
|
+
parsed === null ||
|
|
645
|
+
Array.isArray(parsed)) {
|
|
646
|
+
throw new Error(`Invalid catalog at ${indexPath}: expected name-keyed object`);
|
|
647
|
+
}
|
|
648
|
+
return validateCatalog(indexPath, {
|
|
649
|
+
jawfish: parsed,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
const legacyPath = join(libraryDir, catalogFile);
|
|
653
|
+
if (await exists(legacyPath)) {
|
|
654
|
+
const parsed = JSON.parse(await readFile(legacyPath, "utf8"));
|
|
655
|
+
return validateCatalog(legacyPath, { jawfish: parsed.jawfish ?? {} });
|
|
656
|
+
}
|
|
657
|
+
return { jawfish: {} };
|
|
658
|
+
}
|
|
659
|
+
async function writeCatalog(libraryDir, catalog) {
|
|
660
|
+
await writeJson(join(libraryDir, indexCatalogFile), catalog.jawfish);
|
|
661
|
+
await rm(join(libraryDir, catalogFile), { force: true });
|
|
662
|
+
}
|
|
663
|
+
function validateCatalog(path, catalog) {
|
|
664
|
+
const issues = [];
|
|
665
|
+
for (const [name, entry] of Object.entries(catalog.jawfish)) {
|
|
666
|
+
issues.push(...catalogEntryIssues(name, entry));
|
|
667
|
+
}
|
|
668
|
+
if (issues.length > 0) {
|
|
669
|
+
throw new Error(`Invalid catalog at ${path}: ${issues.join("; ")}`);
|
|
670
|
+
}
|
|
671
|
+
return catalog;
|
|
672
|
+
}
|
|
673
|
+
function catalogEntryIssues(name, entry) {
|
|
674
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
675
|
+
return [`${name}: expected object`];
|
|
676
|
+
}
|
|
677
|
+
const issues = [];
|
|
678
|
+
if (!("description" in entry) || typeof entry.description !== "string") {
|
|
679
|
+
issues.push(`${name}.description`);
|
|
680
|
+
}
|
|
681
|
+
if (!("path" in entry) || typeof entry.path !== "string") {
|
|
682
|
+
issues.push(`${name}.path`);
|
|
683
|
+
}
|
|
684
|
+
if (!("type" in entry) ||
|
|
685
|
+
(entry.type !== "skill" &&
|
|
686
|
+
entry.type !== "agent" &&
|
|
687
|
+
entry.type !== "prompt")) {
|
|
688
|
+
issues.push(`${name}.type`);
|
|
689
|
+
}
|
|
690
|
+
if ("upstream" in entry && typeof entry.upstream !== "string") {
|
|
691
|
+
issues.push(`${name}.upstream`);
|
|
692
|
+
}
|
|
693
|
+
return issues;
|
|
694
|
+
}
|
|
695
|
+
function printCatalogEntry(name, entry, tool) {
|
|
696
|
+
if (entry === undefined) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
console.log(`${name} (${entry.type})`);
|
|
700
|
+
console.log(entry.description);
|
|
701
|
+
console.log(`tool: ${tool}`);
|
|
702
|
+
console.log(`path: ${entry.path}`);
|
|
703
|
+
if (entry.upstream !== undefined) {
|
|
704
|
+
console.log(`upstream: ${entry.upstream}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function readManifest(scope) {
|
|
708
|
+
const path = manifestPath(scope);
|
|
709
|
+
if (!(await exists(path))) {
|
|
710
|
+
return { jawfish: {} };
|
|
711
|
+
}
|
|
712
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
713
|
+
return { jawfish: parsed.jawfish ?? {} };
|
|
714
|
+
}
|
|
715
|
+
async function writeManifest(scope, manifest) {
|
|
716
|
+
await writeJson(manifestPath(scope), manifest);
|
|
717
|
+
}
|
|
718
|
+
async function inferType(packagePath, entryFile) {
|
|
719
|
+
const skillPath = join(packagePath, "SKILL.md");
|
|
720
|
+
const agentPath = join(packagePath, "AGENT.md");
|
|
721
|
+
const detectedTypes = [];
|
|
722
|
+
if (await exists(skillPath)) {
|
|
723
|
+
detectedTypes.push("skill");
|
|
724
|
+
}
|
|
725
|
+
if (await exists(agentPath)) {
|
|
726
|
+
detectedTypes.push("agent");
|
|
727
|
+
}
|
|
728
|
+
if (detectedTypes.length === 0 &&
|
|
729
|
+
(await hasPromptSignal(packagePath, entryFile))) {
|
|
730
|
+
detectedTypes.push("prompt");
|
|
731
|
+
}
|
|
732
|
+
if (detectedTypes.length === 1) {
|
|
733
|
+
return detectedTypes[0];
|
|
734
|
+
}
|
|
735
|
+
const envImportType = process.env.JAWFISH_IMPORT_TYPE;
|
|
736
|
+
if (envImportType !== undefined) {
|
|
737
|
+
if (isAgenticType(envImportType)) {
|
|
738
|
+
return envImportType;
|
|
739
|
+
}
|
|
740
|
+
throw new Error(`Invalid JAWFISH_IMPORT_TYPE: ${envImportType}`);
|
|
741
|
+
}
|
|
742
|
+
return promptForAgenticType(packagePath);
|
|
743
|
+
}
|
|
744
|
+
async function hasPromptSignal(packagePath, entryFile) {
|
|
745
|
+
if (entryFile !== undefined && promptExtensions.has(extname(entryFile))) {
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
const entries = await readdir(packagePath, { withFileTypes: true });
|
|
749
|
+
const promptFiles = entries.filter((entry) => {
|
|
750
|
+
return entry.isFile() && promptExtensions.has(extname(entry.name));
|
|
751
|
+
});
|
|
752
|
+
return promptFiles.length === 1;
|
|
753
|
+
}
|
|
754
|
+
function inferPackageName(packagePath) {
|
|
755
|
+
return basename(packagePath).replace(/\.[^.]+$/, "");
|
|
756
|
+
}
|
|
757
|
+
function configPath() {
|
|
758
|
+
return join(jawfishHome(), "config.json");
|
|
759
|
+
}
|
|
760
|
+
function legacyConfigPath() {
|
|
761
|
+
return join(xdgConfigHome(), "jawfish", "config.json");
|
|
762
|
+
}
|
|
763
|
+
async function existingConfigPath() {
|
|
764
|
+
if (await exists(configPath())) {
|
|
765
|
+
return configPath();
|
|
766
|
+
}
|
|
767
|
+
if (await exists(legacyConfigPath())) {
|
|
768
|
+
return legacyConfigPath();
|
|
769
|
+
}
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
function managedLibraryPath() {
|
|
773
|
+
return jawfishHome();
|
|
774
|
+
}
|
|
775
|
+
function deprecatedLibraryPath() {
|
|
776
|
+
return join(jawfishHome(), "library");
|
|
777
|
+
}
|
|
778
|
+
function manifestPath(scope) {
|
|
779
|
+
if (scope === "project") {
|
|
780
|
+
return join(process.cwd(), projectManifestFile);
|
|
781
|
+
}
|
|
782
|
+
return join(jawfishHome(), projectManifestFile);
|
|
783
|
+
}
|
|
784
|
+
function codexHome() {
|
|
785
|
+
return process.env.CODEX_HOME ?? join(homeDir(), ".codex");
|
|
786
|
+
}
|
|
787
|
+
function toolPaths() {
|
|
788
|
+
return {
|
|
789
|
+
codexHome: codexHome(),
|
|
790
|
+
homeDir: homeDir(),
|
|
791
|
+
projectDir: process.cwd(),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function getScope(args) {
|
|
795
|
+
return args.global ? "global" : "project";
|
|
796
|
+
}
|
|
797
|
+
function homeDir() {
|
|
798
|
+
return process.env.HOME ?? homedir();
|
|
799
|
+
}
|
|
800
|
+
function jawfishHome() {
|
|
801
|
+
return process.env.JAWFISH_HOME ?? join(homeDir(), ".jawfish");
|
|
802
|
+
}
|
|
803
|
+
function xdgConfigHome() {
|
|
804
|
+
return process.env.XDG_CONFIG_HOME ?? join(homeDir(), ".config");
|
|
805
|
+
}
|
|
806
|
+
async function isBareRepository(path) {
|
|
807
|
+
const result = await runCommand("git", ["rev-parse", "--is-bare-repository"], path, false);
|
|
808
|
+
return result.exitCode === 0 && result.stdout.trim() === "true";
|
|
809
|
+
}
|
|
810
|
+
function parseArgs(args) {
|
|
811
|
+
const parsed = {
|
|
812
|
+
force: false,
|
|
813
|
+
global: false,
|
|
814
|
+
help: false,
|
|
815
|
+
positionals: [],
|
|
816
|
+
};
|
|
817
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
818
|
+
const arg = args[index];
|
|
819
|
+
switch (arg) {
|
|
820
|
+
case "-F":
|
|
821
|
+
case "--force":
|
|
822
|
+
parsed.force = true;
|
|
823
|
+
break;
|
|
824
|
+
case "-g":
|
|
825
|
+
case "--global":
|
|
826
|
+
parsed.global = true;
|
|
827
|
+
break;
|
|
828
|
+
case "-h":
|
|
829
|
+
case "--help":
|
|
830
|
+
parsed.help = true;
|
|
831
|
+
break;
|
|
832
|
+
case "--name": {
|
|
833
|
+
const name = args[index + 1];
|
|
834
|
+
if (name === undefined) {
|
|
835
|
+
throw new Error("--name requires a value");
|
|
836
|
+
}
|
|
837
|
+
parsed.name = name;
|
|
838
|
+
index += 1;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
default:
|
|
842
|
+
parsed.positionals.push(arg);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return parsed;
|
|
847
|
+
}
|
|
848
|
+
function printRootHelp() {
|
|
849
|
+
console.log(`jawfish ${version}
|
|
850
|
+
|
|
851
|
+
Usage: jawfish <command> [options]
|
|
852
|
+
|
|
853
|
+
Commands:
|
|
854
|
+
${commandNames
|
|
855
|
+
.map((command) => ` ${command.padEnd(10)}${commandSpecs[command].summary}`)
|
|
856
|
+
.join("\n")}
|
|
857
|
+
|
|
858
|
+
Options:
|
|
859
|
+
-h, --help Show help
|
|
860
|
+
-v, --version Show version`);
|
|
861
|
+
}
|
|
862
|
+
function printCommandHelp(command) {
|
|
863
|
+
const spec = commandSpecs[command];
|
|
864
|
+
console.log(`${spec.description}
|
|
865
|
+
|
|
866
|
+
Usage: ${spec.usage}
|
|
867
|
+
|
|
868
|
+
Options:
|
|
869
|
+
${spec.options.map((option) => ` ${option}`).join("\n")}`);
|
|
870
|
+
}
|
|
871
|
+
async function syncLibrary(libraryDir) {
|
|
872
|
+
if (!(await exists(join(libraryDir, ".git")))) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const upstream = await runCommand("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], libraryDir, false);
|
|
876
|
+
if (upstream.exitCode !== 0) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
await runCommand("git", ["pull", "--ff-only"], libraryDir);
|
|
880
|
+
}
|
|
881
|
+
async function dirtyPaths(libraryDir, packagePath) {
|
|
882
|
+
if (!(await exists(join(libraryDir, ".git")))) {
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
const result = await runCommand("git", ["status", "--porcelain", "--", packagePath], libraryDir);
|
|
886
|
+
return result.stdout
|
|
887
|
+
.split("\n")
|
|
888
|
+
.map((line) => line.trim())
|
|
889
|
+
.filter(Boolean);
|
|
890
|
+
}
|
|
891
|
+
async function commitAndPush(libraryDir, message) {
|
|
892
|
+
if (!(await exists(join(libraryDir, ".git")))) {
|
|
893
|
+
return { ok: true };
|
|
894
|
+
}
|
|
895
|
+
await ensureLibraryIgnore(libraryDir);
|
|
896
|
+
await runCommand("git", ["add", "."], libraryDir);
|
|
897
|
+
const status = await runCommand("git", ["status", "--porcelain"], libraryDir);
|
|
898
|
+
if (status.stdout.trim() === "") {
|
|
899
|
+
return { ok: true };
|
|
900
|
+
}
|
|
901
|
+
await runCommand("git", ["commit", "-m", message], libraryDir);
|
|
902
|
+
const push = await runCommand("git", ["push"], libraryDir, false);
|
|
903
|
+
if (push.exitCode !== 0) {
|
|
904
|
+
return { ok: false, error: push.stderr || push.stdout };
|
|
905
|
+
}
|
|
906
|
+
return { ok: true };
|
|
907
|
+
}
|
|
908
|
+
async function pushLibraryChanges(libraryDir, message) {
|
|
909
|
+
const pushResult = await commitAndPush(libraryDir, message);
|
|
910
|
+
if (pushResult.ok) {
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
printPushFailure(pushResult.error, libraryDir);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
function printPushFailure(error, libraryDir) {
|
|
917
|
+
console.error("Library commit was created, but push failed.");
|
|
918
|
+
console.error(error.trim());
|
|
919
|
+
console.error(`Recover with: git -C ${libraryDir} push`);
|
|
920
|
+
}
|
|
921
|
+
async function runCommand(command, args, cwd, throwOnFailure = true) {
|
|
922
|
+
const child = spawn(command, args, {
|
|
923
|
+
cwd,
|
|
924
|
+
env: process.env,
|
|
925
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
926
|
+
});
|
|
927
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
928
|
+
readStream(child.stdout),
|
|
929
|
+
readStream(child.stderr),
|
|
930
|
+
waitForExit(child),
|
|
931
|
+
]);
|
|
932
|
+
const result = { exitCode, stderr, stdout };
|
|
933
|
+
if (throwOnFailure && exitCode !== 0) {
|
|
934
|
+
throw new Error(`${command} ${args.join(" ")} failed (${exitCode ?? "unknown"})\n${stderr}`);
|
|
935
|
+
}
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
async function writeJson(path, value) {
|
|
939
|
+
await mkdir(dirname(path), { recursive: true });
|
|
940
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
941
|
+
}
|
|
942
|
+
async function ensureLibraryIgnore(libraryDir) {
|
|
943
|
+
const ignorePath = join(libraryDir, ".gitignore");
|
|
944
|
+
const existing = (await exists(ignorePath))
|
|
945
|
+
? await readFile(ignorePath, "utf8")
|
|
946
|
+
: "";
|
|
947
|
+
const existingEntries = new Set(existing.split("\n").map((line) => line.trim()));
|
|
948
|
+
const missing = libraryIgnoreEntries.filter((entry) => !existingEntries.has(entry));
|
|
949
|
+
if (missing.length === 0) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
953
|
+
await writeFile(ignorePath, `${existing}${separator}${missing.join("\n")}\n`);
|
|
954
|
+
}
|
|
955
|
+
async function exists(path) {
|
|
956
|
+
try {
|
|
957
|
+
await stat(path);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function resolveInside(root, path) {
|
|
968
|
+
const resolved = resolve(root, path);
|
|
969
|
+
const parentRelative = relative(root, resolved);
|
|
970
|
+
if (parentRelative.startsWith("..") || isAbsolute(parentRelative)) {
|
|
971
|
+
throw new Error(`Path escapes content library: ${path}`);
|
|
972
|
+
}
|
|
973
|
+
return resolved;
|
|
974
|
+
}
|
|
975
|
+
function isUrl(value) {
|
|
976
|
+
return /^https?:\/\//u.test(value);
|
|
977
|
+
}
|
|
978
|
+
const promptExtensions = new Set([".md", ".txt", ".prompt"]);
|
|
979
|
+
function isHelp(value) {
|
|
980
|
+
return value === "--help" || value === "-h";
|
|
981
|
+
}
|
|
982
|
+
function isCommandName(value) {
|
|
983
|
+
return Object.hasOwn(commandSpecs, value);
|
|
984
|
+
}
|
|
985
|
+
function catalogHasAgentic(catalog, name) {
|
|
986
|
+
return Object.hasOwn(catalog.jawfish, name);
|
|
987
|
+
}
|
|
988
|
+
async function isMainModule() {
|
|
989
|
+
if (process.argv[1] === undefined) {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
const modulePath = await realpath(fileURLToPath(import.meta.url));
|
|
993
|
+
const argvPath = await realpath(process.argv[1]);
|
|
994
|
+
return modulePath === argvPath;
|
|
995
|
+
}
|
|
996
|
+
async function waitForExit(child) {
|
|
997
|
+
return new Promise((resolve, reject) => {
|
|
998
|
+
child.on("close", resolve);
|
|
999
|
+
child.on("error", reject);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
async function readStream(stream) {
|
|
1003
|
+
let output = "";
|
|
1004
|
+
for await (const chunk of stream) {
|
|
1005
|
+
output += String(chunk);
|
|
1006
|
+
}
|
|
1007
|
+
return output;
|
|
1008
|
+
}
|
|
1009
|
+
if (await isMainModule()) {
|
|
1010
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
1011
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export const supportedTools = ["codex", "claude-code", "hermes"];
|
|
3
|
+
const adapters = {
|
|
4
|
+
codex: {
|
|
5
|
+
destinationPath: (name, type, scope, paths) => join(codexRoot(scope, paths), typeFolder(type), name),
|
|
6
|
+
},
|
|
7
|
+
"claude-code": {
|
|
8
|
+
destinationPath: (name, type, scope, paths) => join(scopeRoot(scope, paths), ".claude", typeFolder(type), name),
|
|
9
|
+
},
|
|
10
|
+
hermes: {
|
|
11
|
+
destinationPath: (name, type, scope, paths) => join(scopeRoot(scope, paths), ".hermes", typeFolder(type), name),
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export function assertSupportedTool(tool) {
|
|
15
|
+
if (!isSupportedTool(tool)) {
|
|
16
|
+
throw new Error(`Unsupported tool: ${tool}. Supported tools: ${supportedTools.join(", ")}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function isSupportedTool(tool) {
|
|
20
|
+
return Object.hasOwn(adapters, tool);
|
|
21
|
+
}
|
|
22
|
+
export function destinationPath(name, type, scope, tool, paths) {
|
|
23
|
+
assertSupportedTool(tool);
|
|
24
|
+
return adapters[tool].destinationPath(name, type, scope, paths);
|
|
25
|
+
}
|
|
26
|
+
export function typeFolder(type) {
|
|
27
|
+
switch (type) {
|
|
28
|
+
case "agent":
|
|
29
|
+
return "agents";
|
|
30
|
+
case "prompt":
|
|
31
|
+
return "prompts";
|
|
32
|
+
case "skill":
|
|
33
|
+
return "skills";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function scopeRoot(scope, paths) {
|
|
37
|
+
return scope === "project" ? paths.projectDir : paths.homeDir;
|
|
38
|
+
}
|
|
39
|
+
function codexRoot(scope, paths) {
|
|
40
|
+
return scope === "project" ? join(paths.projectDir, ".codex") : paths.codexHome;
|
|
41
|
+
}
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devdogfish/jawfish",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal CLI for syncing and scoping AI agent skills, agents, and prompts across tools, devices, and projects.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/devdogfish/jawfish.git"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"jawfish-logo-dark.png",
|
|
13
|
+
"jawfish-logo-light.png"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"jawfish": "dist/main.js"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"registry": "https://registry.npmjs.org"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"jawfish": "node --experimental-strip-types src/main.ts",
|
|
24
|
+
"prepack": "bun run build",
|
|
25
|
+
"sandcastle": "npx tsx .sandcastle/main.mts",
|
|
26
|
+
"test": "node --test --experimental-strip-types test/**/*.test.ts",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@ai-hero/sandcastle": "^0.10.0",
|
|
31
|
+
"@clack/prompts": "^1.6.0",
|
|
32
|
+
"zod": "^4.4.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@standard-schema/spec": "^1.1.0",
|
|
36
|
+
"@types/node": "^26.0.0",
|
|
37
|
+
"typescript": "^6.0.3"
|
|
38
|
+
}
|
|
39
|
+
}
|