@2kw/ai 6.2.0-dev.44 → 6.2.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 CHANGED
@@ -110,6 +110,8 @@ kubectl-style contexts switch between organizations and environments:
110
110
  | `analytics` | Usage analytics: spend, quality, providers, errors |
111
111
  | `billing` | Check subscription tier and usage limits |
112
112
  | `agents` | Run agents and decide approvals; apply, export and scaffold agent.yaml; manage versions, labels, approvals and tool catalogs |
113
+ | `skills` | Import and export SKILL.md bundles; manage skill versions and labels |
114
+ | `plugins` | Install plugin repositories, sync them, and inspect their reports |
113
115
  | `conversations` | Create and manage conversations and their items |
114
116
  | `knowledge` | Manage knowledge bases, documents, search, and citations |
115
117
  | `files` | Upload, download, and manage files |
@@ -27,6 +27,21 @@ export function makeAgentsCommand() {
27
27
  });
28
28
  });
29
29
  cmd.addCommand(list);
30
+ // The chat-facing read (#814): published agents, {id, name, description} only, and the
31
+ // one agent endpoint a chat-only USER key may call. `list` needs VIEWER or higher.
32
+ const catalog = new Command("catalog").description("List published agents (id, name, description)");
33
+ addPaginationOptions(catalog);
34
+ catalog.option("-s, --search <term>", "Filter by name");
35
+ catalog.action(async (opts, command) => {
36
+ await runAction(command, async () => {
37
+ const client = getClient(command);
38
+ const { data } = await client.GET("/v1/agents/catalog", {
39
+ params: { query: { search: opts.search, ...paginationParams(opts) } },
40
+ });
41
+ formatPage(data, command, ["id", "name", "description"]);
42
+ });
43
+ });
44
+ cmd.addCommand(catalog);
30
45
  cmd
31
46
  .command("get")
32
47
  .description("Get an agent by ID")
@@ -1,10 +1,11 @@
1
1
  import { Command, Option } from "commander";
2
- import { statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, extname } from "node:path";
4
4
  import { getClient, resolveAuthHeader, runAction } from "../lib/client.js";
5
5
  import { resolveConfig } from "../lib/config.js";
6
6
  import { BackboneApiError } from "../lib/errors.js";
7
7
  import { fileToBlob } from "../lib/multipart.js";
8
+ import { packSkillDirectory, unpackSkillBundle } from "../lib/skill-bundle.js";
8
9
  import { formatPage, formatDetail, formatSuccess, withSpinner } from "../lib/output.js";
9
10
  import { addPaginationOptions, paginationParams } from "../lib/pagination.js";
10
11
  import { makeSkillVersionsCommand } from "./skill-versions.js";
@@ -98,18 +99,29 @@ export function makeSkillsCommand() {
98
99
  });
99
100
  cmd
100
101
  .command("import")
101
- .description("Import a local SKILL.md or zip archive")
102
- .argument("<path>", "Local SKILL.md or .zip file")
102
+ .description("Import a local skill directory, SKILL.md or zip archive")
103
+ .argument("<path>", "Local skill directory, SKILL.md or .zip file")
103
104
  .action(async (path, _opts, command) => {
104
105
  await runAction(command, async () => {
105
- if (statSync(path).isDirectory())
106
- throw new Error("Cannot import a directory; zip it first.");
107
- if (basename(path) !== "SKILL.md" && extname(path).toLowerCase() !== ".zip") {
108
- throw new Error("Import requires a SKILL.md or .zip file.");
106
+ let blob;
107
+ let filename;
108
+ if (statSync(path).isDirectory()) {
109
+ const bundle = packSkillDirectory(path);
110
+ if (bundle.skipped.length > 0) {
111
+ // The server reports what it was sent; only the CLI can name what never left the disk.
112
+ console.error(`Left out of the bundle (the importer takes SKILL.md, references/, assets/, scripts/): ${bundle.skipped.join(", ")}`);
113
+ }
114
+ blob = new Blob([bundle.bytes], { type: "application/zip" });
115
+ filename = bundle.filename;
116
+ }
117
+ else {
118
+ if (basename(path) !== "SKILL.md" && extname(path).toLowerCase() !== ".zip") {
119
+ throw new Error("Import requires a skill directory, a SKILL.md or a .zip file.");
120
+ }
121
+ ({ blob, filename } = fileToBlob(path));
109
122
  }
110
123
  const config = resolveConfig(command);
111
124
  const authHeader = await resolveAuthHeader(config);
112
- const { blob, filename } = fileToBlob(path);
113
125
  const formData = new FormData();
114
126
  formData.append("file", blob, filename);
115
127
  const data = await withSpinner("Importing skill...", async () => {
@@ -126,14 +138,21 @@ export function makeSkillsCommand() {
126
138
  });
127
139
  cmd
128
140
  .command("export")
129
- .description("Export a skill version as a zip archive")
141
+ .description("Export a skill version as a zip archive or into a directory")
130
142
  .argument("<id>", "Skill ID")
131
143
  .argument("<versionNumber>", "Version number")
132
144
  .option("-o, --output <file>", "Output path (default: <skill-name>-v<n>.zip)")
145
+ .option("-d, --extract-to <dir>", "Unpack the bundle into this directory instead of writing a zip")
146
+ .option("--force", "Write into a non-empty --extract-to directory")
133
147
  .action(async (id, versionNumber, opts, command) => {
134
148
  await runAction(command, async () => {
149
+ if (opts.output && opts.extractTo)
150
+ throw new Error("Pass either --output or --extract-to, not both.");
151
+ if (opts.extractTo && existsSync(opts.extractTo) && readdirSync(opts.extractTo).length > 0 && !opts.force) {
152
+ throw new Error(`${opts.extractTo} is not empty. Pass --force to write into it.`);
153
+ }
135
154
  let outputPath = opts.output;
136
- if (!outputPath) {
155
+ if (!outputPath && !opts.extractTo) {
137
156
  const client = getClient(command);
138
157
  const { data } = await client.GET("/v1/skills/{id}", { params: { path: { id } } });
139
158
  if (!data?.name)
@@ -150,6 +169,12 @@ export function makeSkillsCommand() {
150
169
  await checkResponse(res);
151
170
  return Buffer.from(await res.arrayBuffer());
152
171
  });
172
+ if (opts.extractTo) {
173
+ mkdirSync(opts.extractTo, { recursive: true });
174
+ const written = unpackSkillBundle(new Uint8Array(bytes), opts.extractTo);
175
+ formatSuccess(`Wrote ${written.length} file(s) to ${opts.extractTo}: ${written.join(", ")}`, command);
176
+ return;
177
+ }
153
178
  writeFileSync(outputPath, bytes);
154
179
  formatSuccess(`Wrote ${bytes.length} bytes to ${outputPath}`, command);
155
180
  });
@@ -0,0 +1,15 @@
1
+ export interface PackedSkill {
2
+ /** Pinned to an ArrayBuffer so the bytes go straight into a Blob without a cast. */
3
+ bytes: Uint8Array<ArrayBuffer>;
4
+ /** Upload filename: the importer dispatches on the `.zip` suffix, not on the content type. */
5
+ filename: string;
6
+ /** Bundle paths that went in, SKILL.md first. */
7
+ packed: string[];
8
+ /** Directory-relative entries left out, directories with a trailing slash. */
9
+ skipped: string[];
10
+ }
11
+ /** Zip a local skill directory in the layout `POST /v1/skills/import` expects. */
12
+ export declare function packSkillDirectory(dir: string): PackedSkill;
13
+ /** Unpack an exported skill bundle into a directory. Returns the bundle paths written. */
14
+ export declare function unpackSkillBundle(bytes: Uint8Array, dir: string): string[];
15
+ //# sourceMappingURL=skill-bundle.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { unzipSync, zipSync } from "fflate";
4
+ /**
5
+ * The importer takes SKILL.md plus these three directories and reports anything else as
6
+ * `unsupported-path` (spec §4). Packing only these keeps a stray .git or node_modules out of
7
+ * the upload instead of spending the 20 MB bundle cap on it.
8
+ */
9
+ const RESOURCE_DIRS = ["references", "assets", "scripts"];
10
+ function byName(left, right) {
11
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
12
+ }
13
+ /** Collect one resource directory. A stable order keeps the same directory hashing the same. */
14
+ function collect(dir, prefix, files, packed, skipped) {
15
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(byName)) {
16
+ const path = `${prefix}${entry.name}`;
17
+ if (entry.isDirectory())
18
+ collect(join(dir, entry.name), `${path}/`, files, packed, skipped);
19
+ // Symlinks, sockets and devices are reported rather than followed: reading one can leave
20
+ // the directory the user named.
21
+ else if (!entry.isFile())
22
+ skipped.push(path);
23
+ else {
24
+ files[path] = new Uint8Array(readFileSync(join(dir, entry.name)));
25
+ packed.push(path);
26
+ }
27
+ }
28
+ }
29
+ /** Zip a local skill directory in the layout `POST /v1/skills/import` expects. */
30
+ export function packSkillDirectory(dir) {
31
+ const root = readdirSync(dir, { withFileTypes: true }).sort(byName);
32
+ if (!root.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
33
+ throw new Error(`${join(dir, "SKILL.md")} not found: a skill directory holds SKILL.md at its root.`);
34
+ }
35
+ const files = {
36
+ "SKILL.md": new Uint8Array(readFileSync(join(dir, "SKILL.md"))),
37
+ };
38
+ const packed = ["SKILL.md"];
39
+ const skipped = [];
40
+ for (const entry of root) {
41
+ if (entry.isFile() && entry.name === "SKILL.md")
42
+ continue;
43
+ if (entry.isDirectory() && RESOURCE_DIRS.includes(entry.name)) {
44
+ collect(join(dir, entry.name), `${entry.name}/`, files, packed, skipped);
45
+ }
46
+ else
47
+ skipped.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
48
+ }
49
+ return { bytes: new Uint8Array(zipSync(files)), filename: `${basename(resolve(dir))}.zip`, packed, skipped };
50
+ }
51
+ /**
52
+ * Refuse what the backend refuses on import (SkillBundleReader.validatePath), so a crafted
53
+ * archive cannot write outside the directory the user named.
54
+ */
55
+ function assertSafePath(path) {
56
+ const parts = path.split("/");
57
+ if (path === "" ||
58
+ path.startsWith("/") ||
59
+ path.includes("\\") ||
60
+ /^[A-Za-z]:/.test(path) ||
61
+ parts.some((part) => part === ".." || part === ".") ||
62
+ [...path].some((character) => character.charCodeAt(0) < 32)) {
63
+ throw new Error(`The archive holds an unsafe path and was not written: ${path}`);
64
+ }
65
+ }
66
+ /** Unpack an exported skill bundle into a directory. Returns the bundle paths written. */
67
+ export function unpackSkillBundle(bytes, dir) {
68
+ const written = [];
69
+ for (const [path, content] of Object.entries(unzipSync(bytes))) {
70
+ if (path.endsWith("/"))
71
+ continue;
72
+ assertSafePath(path);
73
+ const target = join(dir, ...path.split("/"));
74
+ mkdirSync(dirname(target), { recursive: true });
75
+ writeFileSync(target, content);
76
+ written.push(path);
77
+ }
78
+ return written.sort();
79
+ }
80
+ //# sourceMappingURL=skill-bundle.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.2.0-dev.44",
3
+ "version": "6.2.0",
4
4
  "description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
5
5
  "keywords": [
6
6
  "cli",
@@ -51,6 +51,7 @@
51
51
  "cli-table3": "^0.6.5",
52
52
  "commander": "^13.1.0",
53
53
  "conf": "^13.1.0",
54
+ "fflate": "^0.8.3",
54
55
  "open": "^10.2.0",
55
56
  "openapi-fetch": "^0.13.5",
56
57
  "ora": "^8.2.0",