@yargote/artifacts-cli 0.0.0-stage → 0.1.0-beta.2

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/src/index.js ADDED
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { open, readFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { z } from 'zod';
9
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
10
+ import { canonicalResource } from '@yargote/artifacts-contracts/http';
11
+ import { createArtifactHttpClient, HttpError, prepareArtifactEdit } from '@yargote/artifacts-client';
12
+ import { accessToken, login, logout } from "./auth.js";
13
+ import { loadOperation, retainOperation, submitOperation } from "./operations.js";
14
+ import { runSkillsCommand } from "./skills.js";
15
+ const help = `Usage: artifacts COMMAND [OPTIONS]
16
+
17
+ Global options:
18
+ --server RESOURCE Canonical OAuth resource, or ARTIFACTS_SERVER
19
+ --state-dir DIR Private local state, or ARTIFACTS_STATE_DIR
20
+ Default: ~/.local/state/artifacts
21
+ --help Print this reference
22
+ --version Print the installed package version
23
+
24
+ Commands:
25
+ skills [list]
26
+ skills get NAME [--full]
27
+ skills get --all [--full]
28
+ skills path [NAME]
29
+ skills install [NAME] [--agent claude] [--global] [--force]
30
+ login [--no-browser]
31
+ logout
32
+ whoami
33
+ create --snapshot FILE [--request-id ID] [--response full|compact]
34
+ create --command FILE
35
+ list [--limit N] [--cursor CURSOR]
36
+ read ID [--revision REV] [--response full|compact] [--out FILE [--quiet]]
37
+ update ID --snapshot FILE --expected-revision REV [--request-id ID]
38
+ [--response full|compact]
39
+ update [ID] --command FILE
40
+ edit ID --changes FILE [--request-id ID]
41
+ history ID [--limit N] [--before-sequence N] [--summary]
42
+ publish ID --revision REV --expected-version N [--request-id ID]
43
+ publish [ID] --command FILE
44
+ export ID --revision REV --out FILE
45
+ retry REQUEST_ID
46
+
47
+ Mutation command files contain the complete create, update, or publish JSON.
48
+ Mutation request IDs default to a random UUID. Commands are retained before
49
+ submission. Retry sends the exact retained command for the same server and owner.
50
+ Read --out saves the returned artifact JSON. Export --out saves the ZIP.
51
+ Read --quiet requires --out and suppresses successful JSON stdout.
52
+ Output files are created exclusively and never overwrite an existing path.
53
+ JSON results go to stdout. Diagnostics and recovery guidance go to stderr.
54
+ ARTIFACTS_ACCESS_TOKEN supplies a token for automation without local login.
55
+ Skills commands work offline without a server, login, or private state.
56
+ They print plain text. Global server and state options are accepted and ignored.
57
+ Skills install defaults to artifacts-cli under .agents/skills in the current
58
+ directory. --agent claude uses .claude/skills. --global uses your home directory.
59
+ The installed discovery file loads instructions through artifacts skills get.
60
+ Identical files stay unchanged. Differing files require --force.
61
+ `;
62
+ class UsageError extends Error {
63
+ }
64
+ const commandOptions = {
65
+ login: ['no-browser'], logout: [], whoami: [],
66
+ create: ['snapshot', 'request-id', 'response', 'command'],
67
+ list: ['limit', 'cursor'], read: ['revision', 'response', 'out', 'quiet'],
68
+ update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
69
+ edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
70
+ publish: ['revision', 'expected-version', 'request-id', 'command'],
71
+ export: ['revision', 'out'], retry: [],
72
+ skills: ['full', 'all', 'agent', 'global', 'force'],
73
+ };
74
+ async function jsonFile(path) {
75
+ const file = await open(path, constants.O_RDONLY);
76
+ try {
77
+ const stat = await file.stat();
78
+ if (!stat.isFile() || stat.size > 8 * 1024 * 1024)
79
+ throw new UsageError('JSON input must be a regular file no larger than 8 MiB');
80
+ return JSON.parse(await file.readFile('utf8'));
81
+ }
82
+ finally {
83
+ await file.close();
84
+ }
85
+ }
86
+ async function writeOutput(path, bytes) {
87
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
88
+ try {
89
+ await file.writeFile(bytes);
90
+ await file.sync();
91
+ }
92
+ finally {
93
+ await file.close();
94
+ }
95
+ }
96
+ async function main() {
97
+ const { values, positionals, tokens } = parseArgs({
98
+ allowPositionals: true, tokens: true,
99
+ options: {
100
+ server: { type: 'string' }, 'state-dir': { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' },
101
+ 'no-browser': { type: 'boolean' }, command: { type: 'string' }, snapshot: { type: 'string' },
102
+ changes: { type: 'string' }, 'request-id': { type: 'string' }, response: { type: 'string' },
103
+ revision: { type: 'string' }, 'expected-revision': { type: 'string' }, 'expected-version': { type: 'string' },
104
+ limit: { type: 'string' }, cursor: { type: 'string' }, 'before-sequence': { type: 'string' },
105
+ summary: { type: 'boolean' }, out: { type: 'string' }, quiet: { type: 'boolean' },
106
+ full: { type: 'boolean' }, all: { type: 'boolean' }, agent: { type: 'string' },
107
+ global: { type: 'boolean' }, force: { type: 'boolean' },
108
+ },
109
+ });
110
+ const seen = new Set();
111
+ for (const token of tokens) {
112
+ if (token.kind !== 'option')
113
+ continue;
114
+ if (seen.has(token.name))
115
+ throw new UsageError(`Option --${token.name} cannot be repeated`);
116
+ seen.add(token.name);
117
+ }
118
+ if (values.version) {
119
+ const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
120
+ process.stdout.write(`${manifest.version}\n`);
121
+ return;
122
+ }
123
+ if (values.help) {
124
+ process.stdout.write(help);
125
+ return;
126
+ }
127
+ const [command, ...arguments_] = positionals;
128
+ if (!command || !Object.hasOwn(commandOptions, command))
129
+ throw new UsageError('Specify a command. Use artifacts --help for the reference.');
130
+ for (const option of seen)
131
+ if (!['server', 'state-dir', 'help', ...commandOptions[command]].includes(option))
132
+ throw new UsageError(`Option --${option} is not supported by ${command}`);
133
+ if (command === 'skills')
134
+ return runSkillsCommand(arguments_, values, seen);
135
+ const resourceValue = values.server ?? process.env.ARTIFACTS_SERVER;
136
+ if (!resourceValue)
137
+ throw new UsageError('Specify --server RESOURCE or ARTIFACTS_SERVER. No production server is selected by default.');
138
+ const resource = canonicalResource(resourceValue);
139
+ const stateDir = resolve(values['state-dir'] ?? process.env.ARTIFACTS_STATE_DIR ?? join(homedir(), '.local', 'state', 'artifacts'));
140
+ function argument(required = true) {
141
+ if (arguments_.length > 1 || (required && arguments_.length !== 1))
142
+ throw new UsageError(`${command} requires ${required ? 'one' : 'zero or one'} positional argument`);
143
+ return arguments_[0];
144
+ }
145
+ function none() {
146
+ if (arguments_.length)
147
+ throw new UsageError(`${command} does not accept positional arguments`);
148
+ }
149
+ function required(name) {
150
+ const value = values[name];
151
+ if (typeof value !== 'string' || !value)
152
+ throw new UsageError(`Specify --${name}`);
153
+ return value;
154
+ }
155
+ function integer(name) {
156
+ const value = values[name];
157
+ if (value === undefined)
158
+ return undefined;
159
+ if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value)))
160
+ throw new UsageError(`--${name} requires a nonnegative safe integer`);
161
+ return Number(value);
162
+ }
163
+ const response = values.response;
164
+ if (response !== undefined && response !== 'full' && response !== 'compact')
165
+ throw new UsageError('--response requires full or compact');
166
+ const quiet = values.quiet ?? false;
167
+ if (quiet)
168
+ required('out');
169
+ let commandToken;
170
+ const client = createArtifactHttpClient({ resource, accessToken: () => commandToken ??= accessToken({ resource, stateDir }) });
171
+ async function mutate(mutation) {
172
+ const owner = await client.identity();
173
+ const operation = await retainOperation(stateDir, resource, owner.userId, mutation);
174
+ process.stderr.write(`Retained operation ${operation.command.requestId}. Recover with artifacts retry ${operation.command.requestId} using the same server and state directory.\n`);
175
+ return submitOperation(client, operation);
176
+ }
177
+ async function commandFile(kind) {
178
+ const extra = [...seen].filter((option) => !['server', 'state-dir', 'command'].includes(option));
179
+ if (extra.length)
180
+ throw new UsageError('--command cannot be combined with mutation options');
181
+ const value = await jsonFile(required('command'));
182
+ if (kind === 'create') {
183
+ none();
184
+ return { kind, command: createSchema.parse(value) };
185
+ }
186
+ const mutation = kind === 'update' ? { kind, command: updateSchema.parse(value) } : { kind, command: publishSchema.parse(value) };
187
+ const id = argument(false);
188
+ if (id !== undefined && id !== mutation.command.artifactId)
189
+ throw new UsageError('The positional artifact ID must match the command file');
190
+ return mutation;
191
+ }
192
+ switch (command) {
193
+ case 'login':
194
+ none();
195
+ return login({ resource, stateDir, noBrowser: values['no-browser'] ?? false, onAuthorizationUrl: (url) => { process.stderr.write(`Authorize this login in your browser:\n${url}\n`); } });
196
+ case 'logout':
197
+ none();
198
+ return logout({ resource, stateDir });
199
+ case 'whoami':
200
+ none();
201
+ return client.identity();
202
+ case 'create': {
203
+ if (values.command)
204
+ return mutate(await commandFile('create'));
205
+ none();
206
+ return mutate({ kind: 'create', command: createSchema.parse({ requestId: values['request-id'] ?? randomUUID(), snapshot: await jsonFile(required('snapshot')), ...(response === undefined ? {} : { response }) }) });
207
+ }
208
+ case 'list':
209
+ none();
210
+ return client.list({ ...(values.limit === undefined ? {} : { limit: integer('limit') }), ...(values.cursor === undefined ? {} : { cursor: values.cursor }) });
211
+ case 'read': {
212
+ const result = await client.read({ artifactId: argument(), ...(values.revision === undefined ? {} : { revisionId: values.revision }), ...(response === undefined ? {} : { response }) });
213
+ if (values.out)
214
+ await writeOutput(values.out, `${JSON.stringify(result, null, 2)}\n`);
215
+ return quiet ? undefined : result;
216
+ }
217
+ case 'update': {
218
+ if (values.command)
219
+ return mutate(await commandFile('update'));
220
+ return mutate({ kind: 'update', command: updateSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), expectedRevisionId: required('expected-revision'), snapshot: await jsonFile(required('snapshot')), ...(response === undefined ? {} : { response }) }) });
221
+ }
222
+ case 'edit': {
223
+ const artifactId = argument();
224
+ const changes = await jsonFile(required('changes'));
225
+ const input = await prepareArtifactEdit(client, { artifactId, requestId: values['request-id'] ?? randomUUID(), changes: changes });
226
+ return mutate({ kind: 'update', command: updateSchema.parse(input) });
227
+ }
228
+ case 'history': return client.history({ artifactId: argument(), ...(values.limit === undefined ? {} : { limit: integer('limit') }), ...(values['before-sequence'] === undefined ? {} : { beforeSequence: integer('before-sequence') }), ...(values.summary ? { response: 'summary' } : {}) });
229
+ case 'publish': {
230
+ if (values.command)
231
+ return mutate(await commandFile('publish'));
232
+ required('expected-version');
233
+ return mutate({ kind: 'publish', command: publishSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), revisionId: required('revision'), expectedPublicationVersion: integer('expected-version') }) });
234
+ }
235
+ case 'export': {
236
+ const output = required('out');
237
+ const archive = await client.export({ artifactId: argument(), revisionId: required('revision') });
238
+ await writeOutput(output, archive.bytes);
239
+ return { filename: archive.filename, path: resolve(output), mediaType: archive.mediaType, sha256: archive.sha256, bytes: archive.bytes.byteLength };
240
+ }
241
+ case 'retry': {
242
+ const requestId = argument();
243
+ const owner = await client.identity();
244
+ const operation = await loadOperation(stateDir, requestId, resource, owner.userId);
245
+ process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}. The result is its retained receipt and may differ from current state.\n`);
246
+ return submitOperation(client, operation);
247
+ }
248
+ }
249
+ }
250
+ try {
251
+ const result = await main();
252
+ if (result !== undefined)
253
+ process.stdout.write(`${JSON.stringify(result)}\n`);
254
+ }
255
+ catch (error) {
256
+ const body = error instanceof HttpError
257
+ ? { code: error.code, message: error.message, status: error.status, details: error.details }
258
+ : error instanceof z.ZodError
259
+ ? { code: 'invalid_input', message: 'Input does not match the artifact command schema', details: { issues: error.issues } }
260
+ : { code: error instanceof UsageError || error instanceof TypeError || error instanceof SyntaxError ? 'invalid_arguments' : 'local_error', message: error instanceof Error ? error.message : 'Command failed', details: {} };
261
+ process.stderr.write(`${JSON.stringify({ error: body })}\n`);
262
+ process.exitCode = 1;
263
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod';
2
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
3
+ import type { ArtifactHttpClient } from '@yargote/artifacts-client';
4
+ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
+ kind: z.ZodLiteral<"create">;
6
+ command: z.ZodObject<{
7
+ requestId: z.ZodString;
8
+ snapshot: z.ZodObject<{
9
+ metadata: z.ZodObject<{
10
+ title: z.ZodString;
11
+ description: z.ZodString;
12
+ custom: z.ZodOptional<z.ZodJSONSchema>;
13
+ }, z.core.$strict>;
14
+ content: z.ZodObject<{
15
+ kind: z.ZodEnum<{
16
+ text: "text";
17
+ html: "html";
18
+ markdown: "markdown";
19
+ }>;
20
+ text: z.ZodString;
21
+ }, z.core.$strict>;
22
+ files: z.ZodArray<z.ZodObject<{
23
+ path: z.ZodString;
24
+ mediaType: z.ZodString;
25
+ encoding: z.ZodEnum<{
26
+ utf8: "utf8";
27
+ base64: "base64";
28
+ }>;
29
+ data: z.ZodString;
30
+ }, z.core.$strict>>;
31
+ }, z.core.$strict>;
32
+ response: z.ZodOptional<z.ZodEnum<{
33
+ full: "full";
34
+ compact: "compact";
35
+ }>>;
36
+ }, z.core.$strict>;
37
+ version: z.ZodLiteral<1>;
38
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
39
+ ownerId: z.ZodString;
40
+ }, z.core.$strict>, z.ZodObject<{
41
+ kind: z.ZodLiteral<"update">;
42
+ command: z.ZodObject<{
43
+ requestId: z.ZodString;
44
+ artifactId: z.ZodString;
45
+ expectedRevisionId: z.ZodString;
46
+ snapshot: z.ZodObject<{
47
+ metadata: z.ZodObject<{
48
+ title: z.ZodString;
49
+ description: z.ZodString;
50
+ custom: z.ZodOptional<z.ZodJSONSchema>;
51
+ }, z.core.$strict>;
52
+ content: z.ZodObject<{
53
+ kind: z.ZodEnum<{
54
+ text: "text";
55
+ html: "html";
56
+ markdown: "markdown";
57
+ }>;
58
+ text: z.ZodString;
59
+ }, z.core.$strict>;
60
+ files: z.ZodArray<z.ZodObject<{
61
+ path: z.ZodString;
62
+ mediaType: z.ZodString;
63
+ encoding: z.ZodEnum<{
64
+ utf8: "utf8";
65
+ base64: "base64";
66
+ }>;
67
+ data: z.ZodString;
68
+ }, z.core.$strict>>;
69
+ }, z.core.$strict>;
70
+ response: z.ZodOptional<z.ZodEnum<{
71
+ full: "full";
72
+ compact: "compact";
73
+ }>>;
74
+ }, z.core.$strict>;
75
+ version: z.ZodLiteral<1>;
76
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
77
+ ownerId: z.ZodString;
78
+ }, z.core.$strict>, z.ZodObject<{
79
+ kind: z.ZodLiteral<"publish">;
80
+ command: z.ZodObject<{
81
+ requestId: z.ZodString;
82
+ artifactId: z.ZodString;
83
+ revisionId: z.ZodString;
84
+ expectedPublicationVersion: z.ZodNumber;
85
+ }, z.core.$strict>;
86
+ version: z.ZodLiteral<1>;
87
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
88
+ ownerId: z.ZodString;
89
+ }, z.core.$strict>], "kind">;
90
+ export type PreparedOperation = z.infer<typeof operationSchema>;
91
+ export type Mutation = {
92
+ kind: 'create';
93
+ command: z.input<typeof createSchema>;
94
+ } | {
95
+ kind: 'update';
96
+ command: z.input<typeof updateSchema>;
97
+ } | {
98
+ kind: 'publish';
99
+ command: z.input<typeof publishSchema>;
100
+ };
101
+ export declare function operationPath(stateDir: string, resource: string, requestId: string): string;
102
+ export declare function retainOperation(stateDir: string, resource: string, ownerId: string, mutation: Mutation): Promise<PreparedOperation>;
103
+ export declare function loadOperation(stateDir: string, requestId: string, resource: string, ownerId: string): Promise<PreparedOperation>;
104
+ export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<{
105
+ revisionId: string;
106
+ version: number;
107
+ url: string;
108
+ artifactId: string;
109
+ }> | Promise<{
110
+ artifactId: string;
111
+ ownerId: string;
112
+ headRevisionId: string;
113
+ publication: {
114
+ revisionId: string;
115
+ version: number;
116
+ url: string;
117
+ } | null;
118
+ revision: {
119
+ id: string;
120
+ parentRevisionId: string | null;
121
+ sequence: number;
122
+ authorUserId: string;
123
+ authorClientId: string;
124
+ createdAt: string;
125
+ sha256: string;
126
+ snapshot: {
127
+ metadata: {
128
+ title: string;
129
+ description: string;
130
+ custom?: z.core.util.JSONType | undefined;
131
+ };
132
+ content: {
133
+ kind: "text" | "html" | "markdown";
134
+ text: string;
135
+ };
136
+ files: {
137
+ path: string;
138
+ mediaType: string;
139
+ encoding: "utf8" | "base64";
140
+ data: string;
141
+ }[];
142
+ };
143
+ };
144
+ } | import("@yargote/artifacts-client").CompactArtifactRead>;
@@ -0,0 +1,90 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
7
+ import { canonicalResource } from '@yargote/artifacts-contracts/http';
8
+ const binding = {
9
+ version: z.literal(1),
10
+ resource: z.string().transform(canonicalResource),
11
+ ownerId: z.string().min(1).max(128),
12
+ };
13
+ export const operationSchema = z.discriminatedUnion('kind', [
14
+ z.strictObject({ ...binding, kind: z.literal('create'), command: createSchema }),
15
+ z.strictObject({ ...binding, kind: z.literal('update'), command: updateSchema }),
16
+ z.strictObject({ ...binding, kind: z.literal('publish'), command: publishSchema }),
17
+ ]);
18
+ export function operationPath(stateDir, resource, requestId) {
19
+ createSchema.shape.requestId.parse(requestId);
20
+ return join(stateDir, 'operations', `${createHash('sha256').update(JSON.stringify([canonicalResource(resource), requestId])).digest('hex')}.json`);
21
+ }
22
+ async function privateDirectory(path) {
23
+ await mkdir(path, { recursive: true, mode: 0o700 });
24
+ const stat = await lstat(path);
25
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0))
26
+ throw new Error(`The state directory must be private and owned by the current user: ${path}`);
27
+ }
28
+ export async function retainOperation(stateDir, resource, ownerId, mutation) {
29
+ const operation = operationSchema.parse({ version: 1, resource, ownerId, ...mutation });
30
+ await privateDirectory(stateDir);
31
+ const directory = join(stateDir, 'operations');
32
+ await privateDirectory(directory);
33
+ const path = operationPath(stateDir, resource, operation.command.requestId);
34
+ let file;
35
+ try {
36
+ file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'EEXIST')
40
+ throw new Error(`An operation already exists for ${operation.command.requestId}. Use artifacts retry ${operation.command.requestId} to send the retained command.`);
41
+ throw error;
42
+ }
43
+ try {
44
+ await file.writeFile(`${JSON.stringify(operation)}\n`, 'utf8');
45
+ await file.sync();
46
+ }
47
+ finally {
48
+ await file.close();
49
+ }
50
+ if (process.platform !== 'win32') {
51
+ const handle = await open(directory, constants.O_RDONLY);
52
+ try {
53
+ await handle.sync();
54
+ }
55
+ finally {
56
+ await handle.close();
57
+ }
58
+ }
59
+ return operation;
60
+ }
61
+ export async function loadOperation(stateDir, requestId, resource, ownerId) {
62
+ await privateDirectory(stateDir);
63
+ await privateDirectory(join(stateDir, 'operations'));
64
+ const file = await open(operationPath(stateDir, resource, requestId), constants.O_RDONLY | constants.O_NOFOLLOW);
65
+ let bytes;
66
+ try {
67
+ const stat = await file.stat();
68
+ if (!stat.isFile() || stat.size > 8 * 1024 * 1024 || (process.getuid && stat.uid !== process.getuid()) || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0))
69
+ throw new Error('The retained operation must be a private regular file no larger than 8 MiB');
70
+ bytes = await file.readFile();
71
+ }
72
+ finally {
73
+ await file.close();
74
+ }
75
+ const operation = operationSchema.parse(JSON.parse(bytes.toString('utf8')));
76
+ if (operation.command.requestId !== requestId)
77
+ throw new Error('The retained request ID does not match the requested operation');
78
+ if (operation.resource !== canonicalResource(resource))
79
+ throw new Error('The retained operation belongs to a different server resource');
80
+ if (operation.ownerId !== ownerId)
81
+ throw new Error('The retained operation belongs to a different owner');
82
+ return operation;
83
+ }
84
+ export function submitOperation(client, operation) {
85
+ switch (operation.kind) {
86
+ case 'create': return client.create(operation.command);
87
+ case 'update': return client.update(operation.command);
88
+ case 'publish': return client.publish(operation.command);
89
+ }
90
+ }
@@ -0,0 +1,9 @@
1
+ interface SkillsOptions {
2
+ full?: boolean;
3
+ all?: boolean;
4
+ agent?: string;
5
+ global?: boolean;
6
+ force?: boolean;
7
+ }
8
+ export declare function runSkillsCommand(positionals: string[], options: SkillsOptions, seen: ReadonlySet<string>): Promise<void>;
9
+ export {};
package/src/skills.js ADDED
@@ -0,0 +1,166 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const catalogDirectory = resolve(fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../../../skills/' : '../skills/', import.meta.url)));
8
+ function parseCommand(positionals, options, seen) {
9
+ const [kind = 'list', ...arguments_] = positionals;
10
+ const allowed = { list: [], get: ['all', 'full'], path: [], install: ['agent', 'global', 'force'] };
11
+ if (!Object.hasOwn(allowed, kind))
12
+ throw new TypeError(`Unknown skills command ${kind}. Use artifacts --help for the reference.`);
13
+ for (const option of seen) {
14
+ if (!['server', 'state-dir', ...allowed[kind]].includes(option))
15
+ throw new TypeError(`Option --${option} is not supported by skills ${kind}`);
16
+ }
17
+ switch (kind) {
18
+ case 'list':
19
+ if (arguments_.length)
20
+ throw new TypeError('skills list does not accept positional arguments');
21
+ return { kind };
22
+ case 'get':
23
+ if (arguments_.length > 1 || (options.all ? arguments_.length !== 0 : arguments_.length !== 1))
24
+ throw new TypeError('skills get requires either one skill name or --all');
25
+ return { kind, selection: options.all ? { all: true } : { name: arguments_[0] }, full: options.full ?? false };
26
+ case 'path':
27
+ if (arguments_.length > 1)
28
+ throw new TypeError('skills path accepts zero or one skill name');
29
+ return { kind, name: arguments_[0] };
30
+ case 'install':
31
+ if (arguments_.length > 1)
32
+ throw new TypeError('skills install accepts zero or one skill name');
33
+ if (options.agent !== undefined && options.agent !== 'claude')
34
+ throw new TypeError('--agent requires claude. Omit --agent to install under .agents.');
35
+ return { kind, name: arguments_[0] ?? 'artifacts-cli', agent: options.agent === 'claude' ? 'claude' : 'agents', global: options.global ?? false, force: options.force ?? false };
36
+ default:
37
+ throw new TypeError(`Unknown skills command ${kind}`);
38
+ }
39
+ }
40
+ async function loadCatalog() {
41
+ const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
42
+ const names = manifest.artifactsSkills;
43
+ if (!Array.isArray(names) || names.some((name) => typeof name !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) || new Set(names).size !== names.length)
44
+ throw new Error('Invalid bundled skills declaration');
45
+ return Promise.all(names.toSorted().map(async (name) => {
46
+ const directory = join(catalogDirectory, name);
47
+ const skillText = await readFile(join(directory, 'SKILL.md'));
48
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(skillText.toString('utf8'))?.[1];
49
+ const declaredName = frontmatter && /^name: (.+)$/m.exec(frontmatter)?.[1];
50
+ const description = frontmatter && /^description: (.+)$/m.exec(frontmatter)?.[1];
51
+ if (declaredName !== name || !description)
52
+ throw new Error(`Invalid bundled skill frontmatter at ${join(directory, 'SKILL.md')}`);
53
+ return { name, description, directory, skillText };
54
+ }));
55
+ }
56
+ async function fullFiles(directory, prefix = '') {
57
+ const entries = await readdir(directory, { withFileTypes: true });
58
+ const files = [];
59
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name, 'en'))) {
60
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
61
+ if (entry.isDirectory())
62
+ files.push(...await fullFiles(join(directory, entry.name), relative));
63
+ else if (entry.isFile())
64
+ files.push(relative);
65
+ else
66
+ throw new Error(`Bundled skill contains a non-regular file at ${join(directory, entry.name)}`);
67
+ }
68
+ return files;
69
+ }
70
+ async function labeledContent(skills, full) {
71
+ const chunks = [];
72
+ for (const skill of skills) {
73
+ const files = full ? ['SKILL.md', ...(await fullFiles(skill.directory)).filter((path) => path !== 'SKILL.md')] : ['SKILL.md'];
74
+ for (const path of files) {
75
+ const content = path === 'SKILL.md' ? skill.skillText : await readFile(join(skill.directory, path));
76
+ chunks.push(Buffer.from(`=== ${skill.name}/${path} ===\n`), content);
77
+ if (content.at(-1) !== 10)
78
+ chunks.push(Buffer.from('\n'));
79
+ }
80
+ }
81
+ return Buffer.concat(chunks);
82
+ }
83
+ function discoveryText(skill) {
84
+ return `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\nBefore using this skill, run \`artifacts skills get ${skill.name} --full\`.\nLoad and follow the complete instructions, references, and templates bundled with the installed CLI version.\nRelative paths refer to the bundled directory reported by \`artifacts skills path ${skill.name}\`.\n`;
85
+ }
86
+ async function installSkill(skill, command) {
87
+ const destination = resolve(command.global ? homedir() : process.cwd(), command.agent === 'claude' ? '.claude' : '.agents', 'skills', skill.name, 'SKILL.md');
88
+ const content = Buffer.from(discoveryText(skill));
89
+ await mkdir(dirname(destination), { recursive: true });
90
+ try {
91
+ const file = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
92
+ try {
93
+ await file.writeFile(content);
94
+ }
95
+ finally {
96
+ await file.close();
97
+ }
98
+ return destination;
99
+ }
100
+ catch (error) {
101
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
102
+ throw error;
103
+ }
104
+ const existing = await lstat(destination);
105
+ if (!existing.isFile())
106
+ throw new TypeError(`Cannot install ${destination}. The destination must be a regular file, not a directory or symbolic link.`);
107
+ const file = await open(destination, constants.O_RDONLY | constants.O_NOFOLLOW);
108
+ let previous;
109
+ try {
110
+ if (!(await file.stat()).isFile())
111
+ throw new TypeError(`Cannot install ${destination}. The destination must be a regular file.`);
112
+ previous = await file.readFile();
113
+ }
114
+ finally {
115
+ await file.close();
116
+ }
117
+ if (previous.equals(content))
118
+ return destination;
119
+ if (!command.force)
120
+ throw new TypeError(`Cannot replace differing file ${destination}. Use --force to replace it.`);
121
+ const temporary = join(dirname(destination), `.SKILL.md.${randomUUID()}.tmp`);
122
+ try {
123
+ const replacement = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
124
+ try {
125
+ await replacement.writeFile(content);
126
+ }
127
+ finally {
128
+ await replacement.close();
129
+ }
130
+ const current = await lstat(destination);
131
+ if (!current.isFile())
132
+ throw new TypeError(`Cannot install ${destination}. The destination must be a regular file, not a directory or symbolic link.`);
133
+ if (current.ino !== existing.ino || current.dev !== existing.dev || current.mtimeMs !== existing.mtimeMs || current.ctimeMs !== existing.ctimeMs)
134
+ throw new TypeError(`Cannot replace ${destination}. The destination changed during installation. Run the command again.`);
135
+ await rename(temporary, destination);
136
+ }
137
+ finally {
138
+ await rm(temporary, { force: true });
139
+ }
140
+ return destination;
141
+ }
142
+ export async function runSkillsCommand(positionals, options, seen) {
143
+ const command = parseCommand(positionals, options, seen);
144
+ const skills = await loadCatalog();
145
+ function named(name) {
146
+ const skill = skills.find((entry) => entry.name === name);
147
+ if (!skill)
148
+ throw new TypeError(`Unknown bundled skill ${name}. Use artifacts skills list to inspect available skills.`);
149
+ return skill;
150
+ }
151
+ switch (command.kind) {
152
+ case 'list':
153
+ process.stdout.write(skills.map((skill) => `${skill.name}\t${skill.description}\n`).join(''));
154
+ return;
155
+ case 'get': {
156
+ const selection = 'all' in command.selection ? skills : [named(command.selection.name)];
157
+ process.stdout.write(!command.full && 'name' in command.selection ? selection[0].skillText : await labeledContent(selection, command.full));
158
+ return;
159
+ }
160
+ case 'path':
161
+ process.stdout.write(`${command.name === undefined ? catalogDirectory : named(command.name).directory}\n`);
162
+ return;
163
+ case 'install':
164
+ process.stdout.write(`${await installSkill(named(command.name), command)}\n`);
165
+ }
166
+ }