@canra/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +56 -2
- package/canra.js +467 -0
- package/package.json +33 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Canra Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @canra/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line client for [Canra](https://canra.dev), a registry of machine-readable specifications (library APIs, protocols, data formats). Fetch a spec, generate an implementation in any language, and check it against the spec's test vectors.
|
|
4
|
+
|
|
5
|
+
Runs on Node 20+ or Bun. No install needed:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @canra/cli search json
|
|
9
|
+
npx @canra/cli info canra/json
|
|
10
|
+
npx @canra/cli fetch canra/json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
Or install it to get the `canra` command:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g @canra/cli
|
|
19
|
+
canra search json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Command | What it does |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `canra search <query>` | Search the registry |
|
|
25
|
+
| `canra info <owner>/<name>` | Show a spec's metadata |
|
|
26
|
+
| `canra fetch <owner>/<name>[@version]` | Save a spec's metadata to `.canra/specs/<owner>/<name>/` |
|
|
27
|
+
| `canra outdated` | Check fetched specs for newer versions |
|
|
28
|
+
| `canra login` | Save a registry URL and API token |
|
|
29
|
+
| `canra whoami` | Show which account your saved token belongs to |
|
|
30
|
+
| `canra publish <file.spec.md>` | Publish a spec (needs a token) |
|
|
31
|
+
| `canra logout` | Remove saved credentials |
|
|
32
|
+
| `canra version` / `canra help` | Version and usage |
|
|
33
|
+
|
|
34
|
+
## Publishing
|
|
35
|
+
|
|
36
|
+
Create an API token at **canra.dev → Account → API tokens**, then:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx @canra/cli login # paste the token when asked
|
|
40
|
+
npx @canra/cli publish my-spec.spec.md
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Credentials are stored in `~/.canra/config.json`.
|
|
44
|
+
|
|
45
|
+
## For agents
|
|
46
|
+
|
|
47
|
+
You may not need the CLI at all:
|
|
48
|
+
|
|
49
|
+
- Raw spec markdown: `https://canra.dev/specs/{owner}/{name}.md`
|
|
50
|
+
- Site index for LLMs: `https://canra.dev/llms.txt`
|
|
51
|
+
- Remote MCP server (no auth): `https://canra.dev/mcp`, e.g. `claude mcp add --transport http canra https://canra.dev/mcp`
|
|
52
|
+
|
|
53
|
+
API docs: https://canra.dev/docs/api
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/canra.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
|
|
8
|
+
// Runs under Node >= 20 (via `npx canra`) and Bun: only node: built-ins and fetch.
|
|
9
|
+
// The version comes from package.json, so a release only bumps one place.
|
|
10
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), ".canra");
|
|
12
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
13
|
+
const LOCAL_DIR = ".canra";
|
|
14
|
+
|
|
15
|
+
// --- Config helpers ---
|
|
16
|
+
|
|
17
|
+
function readConfig() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeConfig(config) {
|
|
26
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
27
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getRegistry() {
|
|
31
|
+
const config = readConfig();
|
|
32
|
+
return config.registry || "https://canra.dev";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getToken() {
|
|
36
|
+
const config = readConfig();
|
|
37
|
+
return config.token || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function authHeaders() {
|
|
41
|
+
const token = getToken();
|
|
42
|
+
if (!token) {
|
|
43
|
+
console.error("Error: not logged in. Run `canra login` first.");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
return { Authorization: `Bearer ${token}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch from the registry, exiting with a uniform "could not connect" message on
|
|
51
|
+
* a network failure. Returns the Response; callers handle non-2xx (`res.ok`),
|
|
52
|
+
* which varies per command.
|
|
53
|
+
*/
|
|
54
|
+
async function fetchRegistry(url, opts) {
|
|
55
|
+
try {
|
|
56
|
+
return await fetch(url, opts);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(`Error: could not connect to registry at ${getRegistry()}`);
|
|
59
|
+
console.error(err.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Spec references ---
|
|
65
|
+
|
|
66
|
+
// Mirror the server's naming rules (route-utils.js USERNAME_RE, specs-api.js
|
|
67
|
+
// NAME_RE). Owner and name become local path segments under .canra/specs/, so
|
|
68
|
+
// anything path-like ("..", ".", backslashes) must be rejected here — the CLI is
|
|
69
|
+
// meant to be driven by agents whose arguments may come from untrusted content.
|
|
70
|
+
const OWNER_RE = /^[a-z][a-z0-9_-]{1,38}[a-z0-9]$/;
|
|
71
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
72
|
+
|
|
73
|
+
// Specs are namespaced by (owner, name), like GitHub repos. Parse an
|
|
74
|
+
// "<owner>/<name>[@version]" argument, exiting with usage help on anything else.
|
|
75
|
+
function parseSpecRef(arg, usage) {
|
|
76
|
+
const at = arg.indexOf("@");
|
|
77
|
+
const ref = at === -1 ? arg : arg.slice(0, at);
|
|
78
|
+
const version = at === -1 ? null : arg.slice(at + 1);
|
|
79
|
+
const parts = ref.split("/");
|
|
80
|
+
if (
|
|
81
|
+
parts.length !== 2 ||
|
|
82
|
+
!OWNER_RE.test(parts[0]) ||
|
|
83
|
+
!NAME_RE.test(parts[1]) ||
|
|
84
|
+
version === "" ||
|
|
85
|
+
version?.includes("@")
|
|
86
|
+
) {
|
|
87
|
+
console.error(`Error: specs are referenced as <owner>/<name> (got '${arg}')`);
|
|
88
|
+
console.error(`Usage: ${usage}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
return { owner: parts[0], name: parts[1], version };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function specApiUrl(registry, { owner, name }) {
|
|
95
|
+
return `${registry}/api/specs/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Prompt helper ---
|
|
99
|
+
|
|
100
|
+
function prompt(question) {
|
|
101
|
+
const rl = readline.createInterface({
|
|
102
|
+
input: process.stdin,
|
|
103
|
+
output: process.stdout,
|
|
104
|
+
});
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
rl.question(question, (answer) => {
|
|
107
|
+
rl.close();
|
|
108
|
+
resolve(answer.trim());
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Commands ---
|
|
114
|
+
|
|
115
|
+
async function cmdFetch(specArg) {
|
|
116
|
+
if (!specArg) {
|
|
117
|
+
console.error("Usage: canra fetch <owner>/<name>[@version]");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ref = parseSpecRef(specArg, "canra fetch <owner>/<name>[@version]");
|
|
122
|
+
|
|
123
|
+
const registry = getRegistry();
|
|
124
|
+
const url = specApiUrl(registry, ref);
|
|
125
|
+
|
|
126
|
+
const res = await fetchRegistry(url);
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
const body = await res.text();
|
|
129
|
+
console.error(`Error: ${res.status} - ${body}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const spec = await res.json();
|
|
134
|
+
|
|
135
|
+
// The registry serves only the latest version; never save a different one under
|
|
136
|
+
// a pinned request.
|
|
137
|
+
if (ref.version && ref.version !== spec.version) {
|
|
138
|
+
console.error(
|
|
139
|
+
`Error: ${ref.owner}/${ref.name}@${ref.version} is not available (the registry serves the latest, ${spec.version})`,
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Save metadata locally
|
|
145
|
+
const specDir = path.join(LOCAL_DIR, "specs", ref.owner, ref.name);
|
|
146
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
147
|
+
fs.writeFileSync(path.join(specDir, "metadata.json"), `${JSON.stringify(spec, null, 2)}\n`);
|
|
148
|
+
|
|
149
|
+
const fnCount = (spec.functions || []).length;
|
|
150
|
+
console.log(`Fetched ${ref.owner}/${ref.name}@${spec.version} (${fnCount} function${fnCount !== 1 ? "s" : ""})`);
|
|
151
|
+
console.log(` Saved to ${specDir}/metadata.json`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function cmdSearch(query) {
|
|
155
|
+
if (!query) {
|
|
156
|
+
console.error("Usage: canra search <query>");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const registry = getRegistry();
|
|
161
|
+
const url = `${registry}/api/search?q=${encodeURIComponent(query)}`;
|
|
162
|
+
|
|
163
|
+
const res = await fetchRegistry(url);
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
console.error(`Error: ${res.status}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
// Handle both array response and paginated response
|
|
171
|
+
const specs = Array.isArray(data) ? data : data.specs || [];
|
|
172
|
+
|
|
173
|
+
if (specs.length === 0) {
|
|
174
|
+
console.log("No specs found.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Print formatted table
|
|
179
|
+
const nameWidth = 20;
|
|
180
|
+
const versionWidth = 10;
|
|
181
|
+
const descWidth = 60;
|
|
182
|
+
|
|
183
|
+
console.log(`${"NAME".padEnd(nameWidth)} ${"VERSION".padEnd(versionWidth)} DESCRIPTION`);
|
|
184
|
+
console.log(`${"-".repeat(nameWidth)} ${"-".repeat(versionWidth)} ${"-".repeat(descWidth)}`);
|
|
185
|
+
|
|
186
|
+
for (const spec of specs) {
|
|
187
|
+
const name = (spec.name || "").padEnd(nameWidth).slice(0, nameWidth);
|
|
188
|
+
const version = (spec.version || "").padEnd(versionWidth).slice(0, versionWidth);
|
|
189
|
+
const desc = (spec.description || spec.title || "").slice(0, descWidth).replace(/\n/g, " ");
|
|
190
|
+
console.log(`${name} ${version} ${desc}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`\n${specs.length} result${specs.length !== 1 ? "s" : ""}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cmdPublish(filePath) {
|
|
197
|
+
if (!filePath) {
|
|
198
|
+
console.error("Usage: canra publish <file>");
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!filePath.endsWith(".spec.md")) {
|
|
203
|
+
console.error("Error: file must have .spec.md extension");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!fs.existsSync(filePath)) {
|
|
208
|
+
console.error(`Error: file not found: ${filePath}`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
213
|
+
const registry = getRegistry();
|
|
214
|
+
const headers = authHeaders();
|
|
215
|
+
|
|
216
|
+
const res = await fetchRegistry(`${registry}/api/specs`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: content,
|
|
219
|
+
headers,
|
|
220
|
+
});
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const body = await res.text();
|
|
223
|
+
console.error(`Error: ${res.status} - ${body}`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const spec = await res.json();
|
|
228
|
+
console.log(`Published ${spec.name}@${spec.version}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdLogin() {
|
|
232
|
+
const currentConfig = readConfig();
|
|
233
|
+
const defaultRegistry = currentConfig.registry || "https://canra.dev";
|
|
234
|
+
|
|
235
|
+
const registry = (await prompt(`Registry URL (${defaultRegistry}): `)) || defaultRegistry;
|
|
236
|
+
const token = await prompt("API token: ");
|
|
237
|
+
|
|
238
|
+
if (!token) {
|
|
239
|
+
console.error("Error: token is required");
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Try to verify the token
|
|
244
|
+
try {
|
|
245
|
+
const res = await fetch(`${registry}/api/tokens`, {
|
|
246
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
247
|
+
});
|
|
248
|
+
if (res.ok) {
|
|
249
|
+
console.log("Token verified.");
|
|
250
|
+
} else {
|
|
251
|
+
console.log(`Warning: could not verify token (server returned ${res.status}). Saving anyway.`);
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
console.log("Warning: could not reach registry to verify token. Saving anyway.");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
writeConfig({ registry, token });
|
|
258
|
+
console.log(`Logged in to ${registry}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cmdLogout() {
|
|
262
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
263
|
+
fs.unlinkSync(CONFIG_PATH);
|
|
264
|
+
console.log("Logged out. Credentials removed.");
|
|
265
|
+
} else {
|
|
266
|
+
console.log("Not logged in.");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cmdWhoami() {
|
|
271
|
+
const registry = getRegistry();
|
|
272
|
+
const res = await fetchRegistry(`${registry}/api/auth/whoami`, { headers: authHeaders() });
|
|
273
|
+
if (res.status === 401) {
|
|
274
|
+
console.error(`Error: the saved token was rejected by ${registry}. Run \`canra login\` again.`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
console.error(`Error: ${res.status}`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
const me = await res.json();
|
|
282
|
+
console.log(me.user);
|
|
283
|
+
console.log(` Registry: ${registry}`);
|
|
284
|
+
if (me.scopes?.length) console.log(` Scopes: ${me.scopes.join(", ")}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function cmdInfo(specArg) {
|
|
288
|
+
if (!specArg) {
|
|
289
|
+
console.error("Usage: canra info <owner>/<name>");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ref = parseSpecRef(specArg, "canra info <owner>/<name>");
|
|
294
|
+
|
|
295
|
+
const registry = getRegistry();
|
|
296
|
+
const url = specApiUrl(registry, ref);
|
|
297
|
+
|
|
298
|
+
const res = await fetchRegistry(url);
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
if (res.status === 404) {
|
|
301
|
+
console.error(`Error: spec '${specArg}' not found`);
|
|
302
|
+
} else {
|
|
303
|
+
console.error(`Error: ${res.status}`);
|
|
304
|
+
}
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const spec = await res.json();
|
|
309
|
+
console.log(`${ref.owner}/${ref.name}@${spec.version}`);
|
|
310
|
+
if (spec.title) console.log(` Title: ${spec.title}`);
|
|
311
|
+
if (spec.status) console.log(` Status: ${spec.status}`);
|
|
312
|
+
if (spec.difficulty) console.log(` Difficulty: ${spec.difficulty}`);
|
|
313
|
+
if (spec.tags?.length) console.log(` Tags: ${spec.tags.join(", ")}`);
|
|
314
|
+
if (spec.functions?.length) console.log(` Functions: ${spec.functions.join(", ")}`);
|
|
315
|
+
if (spec.types?.length) console.log(` Types: ${spec.types.join(", ")}`);
|
|
316
|
+
if (spec.test_vectors_inline) console.log(` Test vectors: ${spec.test_vectors_inline} inline`);
|
|
317
|
+
if (spec.owner) console.log(` Owner: ${spec.owner}`);
|
|
318
|
+
if (spec.updated_at) console.log(` Updated: ${spec.updated_at}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function cmdOutdated() {
|
|
322
|
+
const specsDir = path.join(LOCAL_DIR, "specs");
|
|
323
|
+
if (!fs.existsSync(specsDir)) {
|
|
324
|
+
console.log("No local specs found. Run `canra fetch <owner>/<name>` first.");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const registry = getRegistry();
|
|
329
|
+
// Fetched specs live at .canra/specs/<owner>/<name>/metadata.json.
|
|
330
|
+
const refs = [];
|
|
331
|
+
try {
|
|
332
|
+
for (const owner of fs.readdirSync(specsDir)) {
|
|
333
|
+
const ownerDir = path.join(specsDir, owner);
|
|
334
|
+
if (!fs.statSync(ownerDir).isDirectory()) continue;
|
|
335
|
+
for (const name of fs.readdirSync(ownerDir)) {
|
|
336
|
+
if (fs.existsSync(path.join(ownerDir, name, "metadata.json"))) refs.push({ owner, name });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
console.log("No local specs found.");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (refs.length === 0) {
|
|
345
|
+
console.log("No local specs found.");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const nameWidth = 20;
|
|
350
|
+
const versionWidth = 12;
|
|
351
|
+
console.log(`${"SPEC".padEnd(nameWidth)} ${"LOCAL".padEnd(versionWidth)} LATEST`);
|
|
352
|
+
console.log(`${"-".repeat(nameWidth)} ${"-".repeat(versionWidth)} ${"-".repeat(versionWidth)}`);
|
|
353
|
+
|
|
354
|
+
let outdatedCount = 0;
|
|
355
|
+
for (const ref of refs) {
|
|
356
|
+
const label = `${ref.owner}/${ref.name}`;
|
|
357
|
+
const metaPath = path.join(specsDir, ref.owner, ref.name, "metadata.json");
|
|
358
|
+
let local;
|
|
359
|
+
try {
|
|
360
|
+
local = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
361
|
+
} catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let res;
|
|
366
|
+
try {
|
|
367
|
+
res = await fetch(specApiUrl(registry, ref));
|
|
368
|
+
} catch {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (!res.ok) continue;
|
|
372
|
+
|
|
373
|
+
const remote = await res.json();
|
|
374
|
+
if (local.version !== remote.version) {
|
|
375
|
+
console.log(`${label.padEnd(nameWidth)} ${(local.version || "?").padEnd(versionWidth)} ${remote.version}`);
|
|
376
|
+
outdatedCount++;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (outdatedCount === 0) {
|
|
381
|
+
console.log("\nAll specs are up to date.");
|
|
382
|
+
} else {
|
|
383
|
+
console.log(`\n${outdatedCount} outdated spec${outdatedCount !== 1 ? "s" : ""}.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function cmdHelp() {
|
|
388
|
+
console.log(`canra - Package Spec Registry CLI v${VERSION}
|
|
389
|
+
|
|
390
|
+
Usage: canra <command> [args]
|
|
391
|
+
|
|
392
|
+
Commands:
|
|
393
|
+
fetch <owner>/<name>[@version]
|
|
394
|
+
Fetch spec from registry, save to .canra/specs/
|
|
395
|
+
search <query> Search registry, print formatted results
|
|
396
|
+
publish <file> Publish a spec file (requires auth token)
|
|
397
|
+
login Prompt for registry URL and token
|
|
398
|
+
logout Remove saved credentials
|
|
399
|
+
whoami Print the user your saved token authenticates as
|
|
400
|
+
info <owner>/<name> Print spec metadata
|
|
401
|
+
outdated Check local specs for newer versions
|
|
402
|
+
help Print this help message
|
|
403
|
+
version Print CLI version
|
|
404
|
+
|
|
405
|
+
Config stored at ~/.canra/config.json`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cmdVersion() {
|
|
409
|
+
console.log(VERSION);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- Main ---
|
|
413
|
+
|
|
414
|
+
async function main() {
|
|
415
|
+
const args = process.argv.slice(2);
|
|
416
|
+
const command = args[0];
|
|
417
|
+
const arg = args.slice(1).join(" ");
|
|
418
|
+
|
|
419
|
+
switch (command) {
|
|
420
|
+
case "fetch":
|
|
421
|
+
await cmdFetch(args[1]);
|
|
422
|
+
break;
|
|
423
|
+
case "search":
|
|
424
|
+
await cmdSearch(arg);
|
|
425
|
+
break;
|
|
426
|
+
case "publish":
|
|
427
|
+
await cmdPublish(args[1]);
|
|
428
|
+
break;
|
|
429
|
+
case "login":
|
|
430
|
+
await cmdLogin();
|
|
431
|
+
break;
|
|
432
|
+
case "logout":
|
|
433
|
+
cmdLogout();
|
|
434
|
+
break;
|
|
435
|
+
case "whoami":
|
|
436
|
+
await cmdWhoami();
|
|
437
|
+
break;
|
|
438
|
+
case "info":
|
|
439
|
+
await cmdInfo(args[1]);
|
|
440
|
+
break;
|
|
441
|
+
case "outdated":
|
|
442
|
+
await cmdOutdated();
|
|
443
|
+
break;
|
|
444
|
+
case "version":
|
|
445
|
+
case "--version":
|
|
446
|
+
case "-v":
|
|
447
|
+
cmdVersion();
|
|
448
|
+
break;
|
|
449
|
+
case "help":
|
|
450
|
+
case "--help":
|
|
451
|
+
case "-h":
|
|
452
|
+
case undefined:
|
|
453
|
+
cmdHelp();
|
|
454
|
+
break;
|
|
455
|
+
default:
|
|
456
|
+
console.error(`Unknown command: ${command}`);
|
|
457
|
+
console.error('Run "canra help" for usage.');
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
main().catch((err) => {
|
|
463
|
+
console.error(`Error: ${err.message}`);
|
|
464
|
+
process.exit(1);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
export { readConfig, writeConfig, VERSION };
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canra/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Canra, the registry of machine-readable specs: fetch, search, and publish specs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"canra",
|
|
7
|
+
"spec",
|
|
8
|
+
"specification",
|
|
9
|
+
"registry",
|
|
10
|
+
"cli",
|
|
11
|
+
"agents",
|
|
12
|
+
"llm"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://canra.dev/docs/api",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"canra": "canra.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"canra.js",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/canra-dev/canra.git",
|
|
33
|
+
"directory": "cli"
|
|
34
|
+
}
|
|
35
|
+
}
|