@elixpo/lixblogs-cli 1.1.2 → 1.3.3
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/API.md +18 -0
- package/CHANGELOG.md +10 -0
- package/README.md +111 -18
- package/RELEASE.md +30 -0
- package/bin/lixblogs.mjs +358 -46
- package/package.json +8 -2
- package/skills/lixblogs-analytics/SKILL.md +58 -0
- package/skills/lixblogs-analytics/agents/openai.yaml +7 -0
- package/skills/lixblogs-author/SKILL.md +48 -0
- package/skills/lixblogs-author/agents/openai.yaml +7 -0
- package/skills/lixblogs-editorial/SKILL.md +54 -0
- package/skills/lixblogs-editorial/agents/openai.yaml +7 -0
- package/skills/lixblogs-organizations/SKILL.md +44 -0
- package/skills/lixblogs-organizations/agents/openai.yaml +7 -0
- package/skills/lixblogs-publish/SKILL.md +55 -0
- package/skills/lixblogs-publish/agents/openai.yaml +7 -0
- package/src/api/AnalyticsClient.js +40 -0
- package/src/api/BlogClient.js +5 -0
- package/src/api/CollaborationClient.js +73 -0
- package/src/api/OrgClient.js +158 -0
- package/src/auth/AuthenticatedClient.js +15 -0
- package/src/auth/ElixpoAuthProvider.js +1 -1
- package/src/cli/contract.js +46 -0
- package/src/cli/ui.js +54 -0
- package/src/commands/analytics/index.js +57 -0
- package/src/commands/auth/login.js +16 -2
- package/src/commands/auth/profileAlias.js +29 -0
- package/src/commands/blog/index.js +4 -0
- package/src/commands/collab/index.js +53 -0
- package/src/commands/org/index.js +22 -0
- package/src/commands/skill/index.js +83 -0
- package/src/config/providerFactory.js +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const DIMENSIONS = new Set(['overview', 'timeline', 'posts', 'sources', 'devices', 'countries']);
|
|
4
|
+
const RANGES = new Set(['7d', '30d', '90d', '12m', 'custom']);
|
|
5
|
+
|
|
6
|
+
function normalizedOptions(options = {}) {
|
|
7
|
+
const dimension = options.dimension || 'overview';
|
|
8
|
+
const range = options.range || (options.from || options.to ? 'custom' : '30d');
|
|
9
|
+
if (!DIMENSIONS.has(dimension)) throw new Error(`Unsupported analytics dimension: ${dimension}.`);
|
|
10
|
+
if (!RANGES.has(range)) throw new Error(`Unsupported analytics range: ${range}.`);
|
|
11
|
+
if (range === 'custom' && (!options.from || !options.to)) throw new Error('Custom analytics ranges require --from and --to.');
|
|
12
|
+
return {
|
|
13
|
+
scope: options.scope?.[0] || options.publication || 'personal',
|
|
14
|
+
range,
|
|
15
|
+
from: options.from,
|
|
16
|
+
to: options.to,
|
|
17
|
+
dimension,
|
|
18
|
+
limit: options.limit,
|
|
19
|
+
cursor: options.cursor,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function analyticsQuery({ client, options }) {
|
|
24
|
+
return client.query(normalizedOptions(options));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function csvCell(value) {
|
|
28
|
+
const text = value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
29
|
+
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowsFromPayload(payload) {
|
|
33
|
+
const values = payload?.data?.values;
|
|
34
|
+
if (Array.isArray(values)) return values;
|
|
35
|
+
if (values?.labels && Array.isArray(values.labels)) {
|
|
36
|
+
return values.labels.map((label, index) => ({ label, views: values.views?.[index] || 0, reads: values.reads?.[index] || 0 }));
|
|
37
|
+
}
|
|
38
|
+
if (values?.totals) return Object.entries(values.totals).map(([metric, value]) => ({ metric, value, previous: values.previous?.[metric], change: values.changes?.[metric] }));
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function analyticsExport({ client, options }) {
|
|
43
|
+
if (!options.output) throw new Error('Analytics export requires --output <file>.');
|
|
44
|
+
const format = options.format || 'json';
|
|
45
|
+
if (!['json', 'csv'].includes(format)) throw new Error('Analytics export format must be json or csv.');
|
|
46
|
+
const payload = await client.query(normalizedOptions(options));
|
|
47
|
+
let content;
|
|
48
|
+
if (format === 'json') {
|
|
49
|
+
content = `${JSON.stringify(payload, null, 2)}\n`;
|
|
50
|
+
} else {
|
|
51
|
+
const rows = rowsFromPayload(payload);
|
|
52
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
53
|
+
content = `${columns.map(csvCell).join(',')}\n${rows.map((row) => columns.map((column) => csvCell(row[column])).join(',')).join('\n')}\n`;
|
|
54
|
+
}
|
|
55
|
+
await writeFile(options.output, content, { encoding: 'utf8', flag: 'wx' });
|
|
56
|
+
return { output: options.output, format, rows: rowsFromPayload(payload).length };
|
|
57
|
+
}
|
|
@@ -23,6 +23,7 @@ import { redactErrorMessage } from "../../config/redact.js";
|
|
|
23
23
|
* @param {string} params.profileId - which named profile this login is for
|
|
24
24
|
* @param {string[]} params.scopes - scopes being requested
|
|
25
25
|
* @param {(url: string) => Promise<void>} [params.openBrowser] - optional browser opener
|
|
26
|
+
* @param {(params: { accessToken: string, requestedProfileId: string }) => Promise<string>} [params.resolveProfileId]
|
|
26
27
|
* @param {(ms: number) => Promise<void>} [params.sleep] - injectable for tests
|
|
27
28
|
* @param {(...args: any[]) => void} [params.onStatus] - callback for UI updates (verification URL, polling status, etc.)
|
|
28
29
|
* @returns {Promise<{ ok: true, profileId: string } | { ok: false, reason: string }>}
|
|
@@ -33,6 +34,7 @@ export async function authLogin({
|
|
|
33
34
|
profileId,
|
|
34
35
|
scopes,
|
|
35
36
|
openBrowser,
|
|
37
|
+
resolveProfileId,
|
|
36
38
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
39
|
onStatus = () => {},
|
|
38
40
|
}) {
|
|
@@ -69,14 +71,26 @@ export async function authLogin({
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
if (result.status === "approved") {
|
|
72
|
-
|
|
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, {
|
|
73
87
|
accessToken: result.token.accessToken,
|
|
74
88
|
refreshToken: result.token.refreshToken,
|
|
75
89
|
expiresAt: Date.now() + result.token.expiresInSeconds * 1000,
|
|
76
90
|
scopes: result.token.scopes,
|
|
77
91
|
});
|
|
78
92
|
onStatus({ type: "approved" });
|
|
79
|
-
return { ok: true, profileId };
|
|
93
|
+
return { ok: true, profileId: resolvedProfileId };
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
if (result.status === "denied") {
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { metadataFromOptions, resolveMarkdownInput } from './input.js';
|
|
|
4
4
|
import { validateBlogInput } from '../../content/validate.js';
|
|
5
5
|
import { promises as fs } from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { requireConfirmation } from '../../cli/contract.js';
|
|
7
8
|
|
|
8
9
|
export async function blogList({ client, options }) {
|
|
9
10
|
return client.list({ status: options.status, limit: options.limit, cursor: options.cursor });
|
|
@@ -55,6 +56,7 @@ export async function blogPublish({ client, id, options }) {
|
|
|
55
56
|
const current = await client.get(id);
|
|
56
57
|
validateBlogInput(current, { publishing: true });
|
|
57
58
|
if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'published' };
|
|
59
|
+
requireConfirmation(options, 'Publishing this blog');
|
|
58
60
|
return client.publish(id, { etag: options.etag || current.etag, idempotencyKey: options['idempotency-key'] });
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -62,6 +64,7 @@ export async function blogUnpublish({ client, id, options }) {
|
|
|
62
64
|
if (!id) throw new Error('A blog ID is required.');
|
|
63
65
|
const current = await client.get(id);
|
|
64
66
|
if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'draft' };
|
|
67
|
+
requireConfirmation(options, 'Unpublishing this blog');
|
|
65
68
|
return client.unpublish(id, { etag: options.etag || current.etag });
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -77,5 +80,6 @@ export async function blogRestore({ client, id, options }) {
|
|
|
77
80
|
if (!id) throw new Error('A blog ID is required.');
|
|
78
81
|
const current = await client.get(id);
|
|
79
82
|
if (options['dry-run']) return { dryRun: true, id, restoreTo: current.preDeleteStatus || 'draft' };
|
|
83
|
+
requireConfirmation(options, 'Restoring this blog');
|
|
80
84
|
return client.restore(id, { etag: options.etag || current.etag });
|
|
81
85
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
}
|
|
@@ -29,7 +29,7 @@ export function createAuthProvider(config) {
|
|
|
29
29
|
accountsBaseUrl: config.accountsBaseUrl,
|
|
30
30
|
clientId: config.clientId,
|
|
31
31
|
audience: config.audience,
|
|
32
|
-
cliVersion: config.cliVersion || "1.
|
|
32
|
+
cliVersion: config.cliVersion || "1.2.0",
|
|
33
33
|
fetchImpl: config.fetchImpl,
|
|
34
34
|
});
|
|
35
35
|
|