@elixpo/lixblogs-cli 1.3.3 → 1.4.4

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.
Files changed (40) hide show
  1. package/README.md +9 -7
  2. package/dist/lixblogs.mjs +102 -0
  3. package/package.json +9 -10
  4. package/API.md +0 -104
  5. package/CHANGELOG.md +0 -10
  6. package/RELEASE.md +0 -30
  7. package/THREAT_MODEL.md +0 -91
  8. package/bin/lixblogs.mjs +0 -802
  9. package/src/api/AnalyticsClient.js +0 -40
  10. package/src/api/BlogClient.js +0 -140
  11. package/src/api/CollaborationClient.js +0 -73
  12. package/src/api/OrgClient.js +0 -158
  13. package/src/auth/AuthProvider.js +0 -90
  14. package/src/auth/AuthenticatedClient.js +0 -131
  15. package/src/auth/ElixpoAuthProvider.js +0 -281
  16. package/src/auth/MockAuthProvider.js +0 -170
  17. package/src/auth/productionGate.js +0 -44
  18. package/src/cli/contract.js +0 -46
  19. package/src/cli/ui.js +0 -54
  20. package/src/commands/analytics/index.js +0 -57
  21. package/src/commands/auth/login.js +0 -117
  22. package/src/commands/auth/logout.js +0 -21
  23. package/src/commands/auth/profileAlias.js +0 -29
  24. package/src/commands/auth/profiles.js +0 -27
  25. package/src/commands/auth/revoke.js +0 -45
  26. package/src/commands/auth/status.js +0 -33
  27. package/src/commands/blog/index.js +0 -85
  28. package/src/commands/blog/input.js +0 -59
  29. package/src/commands/collab/index.js +0 -53
  30. package/src/commands/org/index.js +0 -22
  31. package/src/commands/skill/index.js +0 -83
  32. package/src/config/CredentialStore.js +0 -142
  33. package/src/config/KeychainCredentialStore.js +0 -180
  34. package/src/config/ProfileRegistry.js +0 -105
  35. package/src/config/config.js +0 -60
  36. package/src/config/credentialStoreFactory.js +0 -63
  37. package/src/config/providerFactory.js +0 -42
  38. package/src/config/redact.js +0 -74
  39. package/src/content/markdown.js +0 -68
  40. package/src/content/validate.js +0 -45
@@ -1,117 +0,0 @@
1
- /**
2
- * lixblogs auth login
3
- *
4
- * Per #135:
5
- * - Display verification URL, user code, expiry, and polling status;
6
- * optionally open the browser
7
- * - Support multiple named accounts/profiles
8
- * - Destructive and publishing scopes require clear consent
9
- *
10
- * This command is deliberately provider-agnostic — it only calls the
11
- * AuthProvider interface, never a concrete implementation. In dev/tests
12
- * this is wired to MockAuthProvider; production wiring goes through
13
- * productionGate.assertProviderAllowed() before this ever runs (see
14
- * bin/lixblogs.mjs for where that check happens).
15
- */
16
-
17
- import { redactErrorMessage } from "../../config/redact.js";
18
-
19
- /**
20
- * @param {Object} params
21
- * @param {import("../../auth/AuthProvider.js").AuthProvider} params.provider
22
- * @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
23
- * @param {string} params.profileId - which named profile this login is for
24
- * @param {string[]} params.scopes - scopes being requested
25
- * @param {(url: string) => Promise<void>} [params.openBrowser] - optional browser opener
26
- * @param {(params: { accessToken: string, requestedProfileId: string }) => Promise<string>} [params.resolveProfileId]
27
- * @param {(ms: number) => Promise<void>} [params.sleep] - injectable for tests
28
- * @param {(...args: any[]) => void} [params.onStatus] - callback for UI updates (verification URL, polling status, etc.)
29
- * @returns {Promise<{ ok: true, profileId: string } | { ok: false, reason: string }>}
30
- */
31
- export async function authLogin({
32
- provider,
33
- credentialStore,
34
- profileId,
35
- scopes,
36
- openBrowser,
37
- resolveProfileId,
38
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
39
- onStatus = () => {},
40
- }) {
41
- let deviceCode;
42
- try {
43
- deviceCode = await provider.requestDeviceCode({ scopes });
44
- } catch (err) {
45
- return { ok: false, reason: redactErrorMessage(err.message) };
46
- }
47
-
48
- onStatus({
49
- type: "verification_pending",
50
- verificationUri: deviceCode.verificationUri,
51
- verificationUriComplete: deviceCode.verificationUriComplete,
52
- userCode: deviceCode.userCode,
53
- expiresInSeconds: deviceCode.expiresInSeconds,
54
- });
55
-
56
- if (openBrowser) {
57
- await openBrowser(deviceCode.verificationUriComplete || deviceCode.verificationUri);
58
- }
59
-
60
- let pollIntervalMs = deviceCode.pollIntervalSeconds * 1000;
61
- const deadline = Date.now() + deviceCode.expiresInSeconds * 1000;
62
-
63
- while (Date.now() < deadline) {
64
- await sleep(pollIntervalMs);
65
-
66
- let result;
67
- try {
68
- result = await provider.pollDeviceCode({ deviceCode: deviceCode.deviceCode });
69
- } catch (err) {
70
- return { ok: false, reason: redactErrorMessage(err.message) };
71
- }
72
-
73
- if (result.status === "approved") {
74
- let resolvedProfileId = profileId;
75
- if (resolveProfileId) {
76
- try {
77
- resolvedProfileId = await resolveProfileId({
78
- accessToken: result.token.accessToken,
79
- requestedProfileId: profileId,
80
- });
81
- } catch (err) {
82
- return { ok: false, reason: redactErrorMessage(err.message) };
83
- }
84
- }
85
-
86
- await credentialStore.set(resolvedProfileId, {
87
- accessToken: result.token.accessToken,
88
- refreshToken: result.token.refreshToken,
89
- expiresAt: Date.now() + result.token.expiresInSeconds * 1000,
90
- scopes: result.token.scopes,
91
- });
92
- onStatus({ type: "approved" });
93
- return { ok: true, profileId: resolvedProfileId };
94
- }
95
-
96
- if (result.status === "denied") {
97
- onStatus({ type: "denied" });
98
- return { ok: false, reason: "Login was denied." };
99
- }
100
-
101
- if (result.status === "expired") {
102
- onStatus({ type: "expired" });
103
- return { ok: false, reason: "Device code expired before login was approved." };
104
- }
105
-
106
- if (result.status === "slow_down") {
107
- pollIntervalMs += result.pollIntervalIncreaseSeconds * 1000;
108
- onStatus({ type: "slow_down", newIntervalMs: pollIntervalMs });
109
- continue;
110
- }
111
-
112
- // status === "pending" — keep polling
113
- onStatus({ type: "pending" });
114
- }
115
-
116
- return { ok: false, reason: "Device code expired before login was approved." };
117
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * lixblogs auth logout
3
- *
4
- * Clears locally stored credentials for a profile. This is a *local*
5
- * operation — it does not revoke the token server-side (that's `revoke`).
6
- * A user who just wants to stop using this machine, without invalidating
7
- * the token everywhere, should be able to do that — this command is that
8
- * lighter-weight action. See revoke.js for the destructive, server-side
9
- * equivalent.
10
- */
11
-
12
- /**
13
- * @param {Object} params
14
- * @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
15
- * @param {string} params.profileId
16
- * @returns {Promise<{ ok: true }>}
17
- */
18
- export async function authLogout({ credentialStore, profileId }) {
19
- await credentialStore.delete(profileId);
20
- return { ok: true };
21
- }
@@ -1,29 +0,0 @@
1
- import { validateProfileId } from "../../config/ProfileRegistry.js";
2
-
3
- /** Resolve the authenticated Accounts username without persisting a temporary profile. */
4
- export async function profileAliasFromIdentity({
5
- accessToken,
6
- apiBaseUrl,
7
- fetchImpl = globalThis.fetch,
8
- }) {
9
- const endpoint = new URL("/api/v1/me", apiBaseUrl);
10
- const response = await fetchImpl(endpoint, {
11
- headers: {
12
- accept: "application/json",
13
- authorization: `Bearer ${accessToken}`,
14
- },
15
- });
16
-
17
- let payload;
18
- try {
19
- payload = await response.json();
20
- } catch {
21
- throw new Error("LixBlogs could not resolve the signed-in username.");
22
- }
23
-
24
- if (!response.ok || typeof payload?.data?.username !== "string") {
25
- throw new Error(payload?.error?.message || "LixBlogs could not resolve the signed-in username.");
26
- }
27
-
28
- return validateProfileId(payload.data.username);
29
- }
@@ -1,27 +0,0 @@
1
- /** List known profiles without exposing credentials. */
2
- export async function authProfiles({ credentialStore, profileRegistry }) {
3
- const activeProfile = await profileRegistry.getActive();
4
- const profileIds = await credentialStore.listProfiles();
5
- const profiles = [];
6
- for (const profileId of profileIds) {
7
- const credentials = await credentialStore.get(profileId);
8
- profiles.push({
9
- profileId,
10
- active: profileId === activeProfile,
11
- loggedIn: Boolean(credentials),
12
- expired: credentials ? Date.now() >= credentials.expiresAt : undefined,
13
- scopes: credentials?.scopes || [],
14
- });
15
- }
16
- return { activeProfile, profiles };
17
- }
18
-
19
- /** Select the profile used when --profile is omitted. */
20
- export async function authUse({ credentialStore, profileRegistry, profileId }) {
21
- const credentials = await credentialStore.get(profileId);
22
- if (!credentials) {
23
- return { ok: false, reason: `Profile "${profileId}" is not logged in.` };
24
- }
25
- await profileRegistry.setActive(profileId);
26
- return { ok: true, profileId };
27
- }
@@ -1,45 +0,0 @@
1
- /**
2
- * lixblogs auth revoke
3
- *
4
- * Revokes the token server-side (via the AuthProvider) AND clears local
5
- * storage. This is destructive — per #135, "destructive and publishing
6
- * scopes require clear consent" and "destructive commands cannot run
7
- * accidentally in a non-interactive session."
8
- *
9
- * This function does not itself prompt — that's the CLI shell's job
10
- * (interactive confirmation prompt, or requiring an explicit --yes flag in
11
- * non-interactive mode). This function requires the caller to have already
12
- * obtained consent and pass confirmed: true; if confirmed is not exactly
13
- * true, it refuses to proceed. This makes "forgot to check for consent"
14
- * impossible to do accidentally at the call site.
15
- */
16
-
17
- /**
18
- * @param {Object} params
19
- * @param {import("../../auth/AuthProvider.js").AuthProvider} params.provider
20
- * @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
21
- * @param {string} params.profileId
22
- * @param {boolean} params.confirmed - must be exactly `true`; caller is
23
- * responsible for having obtained real user consent before setting this.
24
- * @returns {Promise<{ ok: true } | { ok: false, reason: string }>}
25
- */
26
- export async function authRevoke({ provider, credentialStore, profileId, confirmed }) {
27
- if (confirmed !== true) {
28
- return {
29
- ok: false,
30
- reason:
31
- "Revoke was not confirmed. This is a destructive action and requires " +
32
- "explicit confirmation (interactive prompt, or --yes in a non-interactive session).",
33
- };
34
- }
35
-
36
- const credentials = await credentialStore.get(profileId);
37
- if (!credentials) {
38
- return { ok: false, reason: `No stored credentials for profile "${profileId}".` };
39
- }
40
-
41
- await provider.revoke({ token: credentials.refreshToken });
42
- await credentialStore.delete(profileId);
43
-
44
- return { ok: true };
45
- }
@@ -1,33 +0,0 @@
1
- /**
2
- * lixblogs auth status
3
- *
4
- * Shows whether the given profile (or all profiles) is logged in, and
5
- * whether its token is expired. Never prints the token itself — only
6
- * derived, safe-to-display metadata (per #135's redaction requirement).
7
- */
8
-
9
- /**
10
- * @param {Object} params
11
- * @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
12
- * @param {string} [params.profileId] - if omitted, reports on all profiles
13
- * @returns {Promise<Array<{ profileId: string, loggedIn: boolean, expired?: boolean, scopes?: string[] }>>}
14
- */
15
- export async function authStatus({ credentialStore, profileId }) {
16
- const profileIds = profileId ? [profileId] : await credentialStore.listProfiles();
17
-
18
- const results = [];
19
- for (const id of profileIds) {
20
- const credentials = await credentialStore.get(id);
21
- if (!credentials) {
22
- results.push({ profileId: id, loggedIn: false });
23
- continue;
24
- }
25
- results.push({
26
- profileId: id,
27
- loggedIn: true,
28
- expired: Date.now() >= credentials.expiresAt,
29
- scopes: credentials.scopes,
30
- });
31
- }
32
- return results;
33
- }
@@ -1,85 +0,0 @@
1
- import { blocksToMarkdown } from '../../content/markdown.js';
2
- import { BlogApiError } from '../../api/BlogClient.js';
3
- import { metadataFromOptions, resolveMarkdownInput } from './input.js';
4
- import { validateBlogInput } from '../../content/validate.js';
5
- import { promises as fs } from 'node:fs';
6
- import path from 'node:path';
7
- import { requireConfirmation } from '../../cli/contract.js';
8
-
9
- export async function blogList({ client, options }) {
10
- return client.list({ status: options.status, limit: options.limit, cursor: options.cursor });
11
- }
12
-
13
- export async function blogGet({ client, id }) {
14
- if (!id) throw new Error('A blog ID is required.');
15
- const blog = await client.get(id);
16
- return { ...blog, markdown: blocksToMarkdown(blog.content) };
17
- }
18
-
19
- export async function blogCreate({ client, options, stdin }) {
20
- const source = await resolveMarkdownInput(options, { stdin });
21
- const input = { ...metadataFromOptions(options), content: source?.blocks || [] };
22
- validateBlogInput(input);
23
- if (options['dry-run']) return { dryRun: true, input, markdown: source?.markdown || '' };
24
- return client.create(input, { idempotencyKey: options['idempotency-key'] });
25
- }
26
-
27
- export async function blogEdit({ client, id, options, stdin }) {
28
- if (!id) throw new Error('A blog ID is required.');
29
- const current = await client.get(id);
30
- const source = await resolveMarkdownInput(options, { stdin, initial: blocksToMarkdown(current.content) });
31
- const input = { ...metadataFromOptions(options), ...(source ? { content: source.blocks } : {}) };
32
- if (!Object.keys(input).length) throw new Error('No blog changes were provided.');
33
- validateBlogInput(input);
34
- if (options['dry-run']) return { dryRun: true, id, etag: current.etag, input, markdown: source?.markdown };
35
- try {
36
- return await client.update(id, input, { etag: options.etag || current.etag });
37
- } catch (error) {
38
- if (!(error instanceof BlogApiError) || error.code !== 'revision_conflict') throw error;
39
- const server = await client.get(id);
40
- const directory = options.conflictDirectory || path.resolve('.lixblogs-conflicts');
41
- await fs.mkdir(directory, { recursive: true });
42
- const safeId = id.replace(/[^A-Za-z0-9._-]/g, '_');
43
- const localPath = path.join(directory, `${safeId}-local.json`);
44
- const serverPath = path.join(directory, `${safeId}-server.md`);
45
- await Promise.all([
46
- fs.writeFile(localPath, JSON.stringify(input, null, 2), { mode: 0o600 }),
47
- fs.writeFile(serverPath, blocksToMarkdown(server.content), { mode: 0o600 }),
48
- ]);
49
- error.details = { ...error.details, localPath, serverPath, serverEtag: server.etag };
50
- throw error;
51
- }
52
- }
53
-
54
- export async function blogPublish({ client, id, options }) {
55
- if (!id) throw new Error('A blog ID is required.');
56
- const current = await client.get(id);
57
- validateBlogInput(current, { publishing: true });
58
- if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'published' };
59
- requireConfirmation(options, 'Publishing this blog');
60
- return client.publish(id, { etag: options.etag || current.etag, idempotencyKey: options['idempotency-key'] });
61
- }
62
-
63
- export async function blogUnpublish({ client, id, options }) {
64
- if (!id) throw new Error('A blog ID is required.');
65
- const current = await client.get(id);
66
- if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'draft' };
67
- requireConfirmation(options, 'Unpublishing this blog');
68
- return client.unpublish(id, { etag: options.etag || current.etag });
69
- }
70
-
71
- export async function blogDelete({ client, id, options }) {
72
- if (!id) throw new Error('A blog ID is required.');
73
- if (!options.yes) throw new Error('Deletion requires --yes. Trash is the default; add --permanent for irreversible deletion.');
74
- const current = await client.get(id);
75
- if (options['dry-run']) return { dryRun: true, id, permanent: options.permanent };
76
- return client.delete(id, { etag: options.etag || current.etag, permanent: options.permanent });
77
- }
78
-
79
- export async function blogRestore({ client, id, options }) {
80
- if (!id) throw new Error('A blog ID is required.');
81
- const current = await client.get(id);
82
- if (options['dry-run']) return { dryRun: true, id, restoreTo: current.preDeleteStatus || 'draft' };
83
- requireConfirmation(options, 'Restoring this blog');
84
- return client.restore(id, { etag: options.etag || current.etag });
85
- }
@@ -1,59 +0,0 @@
1
- import { promises as fs } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
- import { markdownToBlocks } from '../../content/markdown.js';
6
-
7
- async function stdinText(stream) {
8
- let value = '';
9
- stream.setEncoding('utf8');
10
- for await (const chunk of stream) value += chunk;
11
- return value;
12
- }
13
-
14
- async function editText(initial = '', editor = process.env.EDITOR || process.env.VISUAL) {
15
- if (!editor) throw new Error('$EDITOR or $VISUAL must be set when using --editor.');
16
- const directory = await fs.mkdtemp(path.join(tmpdir(), 'lixblogs-'));
17
- const filename = path.join(directory, 'post.md');
18
- await fs.writeFile(filename, initial, { mode: 0o600 });
19
- try {
20
- await new Promise((resolve, reject) => {
21
- const child = spawn(editor, [filename], { stdio: 'inherit', shell: true });
22
- child.once('error', reject);
23
- child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`Editor exited with code ${code}.`)));
24
- });
25
- return await fs.readFile(filename, 'utf8');
26
- } finally {
27
- await fs.rm(directory, { recursive: true, force: true });
28
- }
29
- }
30
-
31
- export async function resolveMarkdownInput(options, { stdin = process.stdin, initial = '' } = {}) {
32
- const selected = [options.file !== undefined, options.stdin, options.content !== undefined, options.editor]
33
- .filter(Boolean).length;
34
- if (selected > 1) throw new Error('Use only one of --file, --stdin, --content, or --editor.');
35
- if (!selected) return null;
36
- let markdown;
37
- if (options.file !== undefined) markdown = await fs.readFile(path.resolve(options.file), 'utf8');
38
- else if (options.stdin) markdown = await stdinText(stdin);
39
- else if (options.content !== undefined) markdown = options.content;
40
- else markdown = await editText(initial);
41
- return { markdown, blocks: markdownToBlocks(markdown) };
42
- }
43
-
44
- export function metadataFromOptions(options) {
45
- const input = {};
46
- const mappings = {
47
- title: 'title', subtitle: 'subtitle', slug: 'slug', emoji: 'emoji',
48
- publication: 'publishedAs', collection: 'collectionId', cover: 'coverUrl',
49
- };
50
- for (const [option, field] of Object.entries(mappings)) {
51
- if (options[option] !== undefined) input[field] = options[option];
52
- }
53
- if (options.tag !== undefined) input.tags = options.tag;
54
- if (options['member-only']) input.memberOnly = true;
55
- if (options['no-member-only']) input.memberOnly = false;
56
- if (options.secret) input.secret = true;
57
- if (options['not-secret']) input.secret = false;
58
- return input;
59
- }
@@ -1,53 +0,0 @@
1
- import { requireConfirmation } from '../../cli/contract.js';
2
-
3
- function requireBlogId(id) {
4
- if (!id) throw new Error('A blog ID is required.');
5
- }
6
-
7
- export async function collabList({ client, id }) {
8
- requireBlogId(id);
9
- return client.list(id);
10
- }
11
-
12
- export async function collabInvitations({ client }) {
13
- return client.invitations();
14
- }
15
-
16
- export async function collabInvite({ client, id, options }) {
17
- requireBlogId(id);
18
- if (!options.user) throw new Error('--user is required.');
19
- if (!['viewer', 'editor', 'admin'].includes(options.role)) throw new Error('--role must be viewer, editor, or admin.');
20
- if (options['dry-run']) return { dryRun: true, action: 'invite', blogId: id, user: options.user, role: options.role };
21
- requireConfirmation(options, 'Inviting this collaborator');
22
- return client.invite(id, { user: options.user, role: options.role, idempotencyKey: options['idempotency-key'] });
23
- }
24
-
25
- export async function collabRole({ client, id, options }) {
26
- requireBlogId(id);
27
- if (!options.user) throw new Error('--user is required.');
28
- if (!['viewer', 'editor', 'admin'].includes(options.role)) throw new Error('--role must be viewer, editor, or admin.');
29
- if (options['dry-run']) return { dryRun: true, action: 'role', blogId: id, user: options.user, role: options.role };
30
- requireConfirmation(options, 'Changing this collaborator role');
31
- return client.role(id, { user: options.user, role: options.role, idempotencyKey: options['idempotency-key'] });
32
- }
33
-
34
- export async function collabRemove({ client, id, options }) {
35
- requireBlogId(id);
36
- if (options['dry-run']) return { dryRun: true, action: 'remove', blogId: id, user: options.user || 'self' };
37
- requireConfirmation(options, 'Removing this collaborator or invitation');
38
- return client.remove(id, { user: options.user, idempotencyKey: options['idempotency-key'] });
39
- }
40
-
41
- export async function collabAccept({ client, id, options }) {
42
- requireBlogId(id);
43
- if (options['dry-run']) return { dryRun: true, action: 'accept', blogId: id, showOnProfile: !options['hide-on-profile'] };
44
- requireConfirmation(options, 'Accepting this collaboration invitation');
45
- return client.resolveInvitation(id, { action: 'accept', showOnProfile: !options['hide-on-profile'], idempotencyKey: options['idempotency-key'] });
46
- }
47
-
48
- export async function collabDecline({ client, id, options }) {
49
- requireBlogId(id);
50
- if (options['dry-run']) return { dryRun: true, action: 'decline', blogId: id };
51
- requireConfirmation(options, 'Declining this collaboration invitation');
52
- return client.resolveInvitation(id, { action: 'decline', idempotencyKey: options['idempotency-key'] });
53
- }
@@ -1,22 +0,0 @@
1
- export async function orgList({ client }) {
2
- return client.list();
3
- }
4
-
5
- export async function orgGet({ client, id }) {
6
- if (!id) throw new Error("An organization ID or handle is required.");
7
- return client.get(id);
8
- }
9
-
10
- export async function orgCollections({ client, id }) {
11
- if (!id) throw new Error("An organization ID or handle is required.");
12
- return client.collections(id);
13
- }
14
-
15
- export async function orgMembers({ client, id }) {
16
- if (!id) throw new Error("An organization ID or handle is required.");
17
- return client.members(id);
18
- }
19
-
20
- export async function orgTargets({ client }) {
21
- return client.targets();
22
- }
@@ -1,83 +0,0 @@
1
- import { access, cp, readFile, readdir } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { requireConfirmation } from '../../cli/contract.js';
5
-
6
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
7
- const bundledRoot = path.join(packageRoot, 'skills');
8
- const developmentRoot = path.resolve(packageRoot, '../..', '.agents', 'skills');
9
-
10
- async function exists(candidate) {
11
- try { await access(candidate); return true; } catch { return false; }
12
- }
13
-
14
- async function root() {
15
- if (await exists(bundledRoot)) return bundledRoot;
16
- if (await exists(developmentRoot)) return developmentRoot;
17
- const error = new Error('No bundled LixBlogs skills were found. Reinstall @elixpo/lixblogs-cli.');
18
- error.code = 'skills_unavailable';
19
- throw error;
20
- }
21
-
22
- function validateName(name) {
23
- if (!/^lixblogs-[a-z0-9-]+$/.test(name || '')) {
24
- const error = new Error('A valid lixblogs-* skill name is required.');
25
- error.code = 'invalid_skill_name';
26
- throw error;
27
- }
28
- return name;
29
- }
30
-
31
- async function metadata(directory, name) {
32
- const content = await readFile(path.join(directory, name, 'SKILL.md'), 'utf8');
33
- const description = content.match(/^description:\s*(.+)$/m)?.[1] || content.match(/^description:\s*>-\s*\n\s*(.+)$/m)?.[1] || '';
34
- const minimumCliVersion = content.match(/`@elixpo\/lixblogs-cli`\s+([0-9.]+)/)?.[1] || null;
35
- return { name, description: description.trim(), minimumCliVersion, content };
36
- }
37
-
38
- export async function skillList() {
39
- const directory = await root();
40
- const entries = await readdir(directory, { withFileTypes: true });
41
- return Promise.all(entries
42
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('lixblogs-'))
43
- .map((entry) => metadata(directory, entry.name))
44
- ).then((skills) => skills.map(({ content: _content, ...skill }) => skill).sort((a, b) => a.name.localeCompare(b.name)));
45
- }
46
-
47
- export async function skillInspect({ name }) {
48
- const directory = await root();
49
- const skillName = validateName(name);
50
- if (!(await exists(path.join(directory, skillName, 'SKILL.md')))) {
51
- const error = new Error(`Skill "${skillName}" is not bundled.`);
52
- error.code = 'skill_not_found';
53
- throw error;
54
- }
55
- return metadata(directory, skillName);
56
- }
57
-
58
- export async function skillInstall({ name, options }) {
59
- const directory = await root();
60
- const skillName = validateName(name);
61
- const source = path.join(directory, skillName);
62
- if (!(await exists(path.join(source, 'SKILL.md')))) {
63
- const error = new Error(`Skill "${skillName}" is not bundled.`);
64
- error.code = 'skill_not_found';
65
- throw error;
66
- }
67
- const targetRoot = path.resolve(options.target || '.agents/skills');
68
- const target = path.join(targetRoot, skillName);
69
- if (options['dry-run']) return { dryRun: true, name: skillName, target, replace: await exists(target) };
70
- if (await exists(target)) {
71
- if (!options.force) {
72
- const error = new Error(`Skill already exists at ${target}.`);
73
- error.code = 'skill_exists';
74
- error.hint = 'Inspect the existing skill or re-run with --force --yes to replace it.';
75
- throw error;
76
- }
77
- requireConfirmation(options, `Replacing ${target}`);
78
- } else {
79
- requireConfirmation(options, `Installing ${skillName} into ${targetRoot}`);
80
- }
81
- await cp(source, target, { recursive: true, force: Boolean(options.force) });
82
- return { installed: true, name: skillName, target };
83
- }