@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/bin/lixblogs.mjs CHANGED
@@ -31,9 +31,15 @@ import { authStatus } from "../src/commands/auth/status.js";
31
31
  import { authLogout } from "../src/commands/auth/logout.js";
32
32
  import { authRevoke } from "../src/commands/auth/revoke.js";
33
33
  import { authProfiles, authUse } from "../src/commands/auth/profiles.js";
34
+ import { profileAliasFromIdentity } from "../src/commands/auth/profileAlias.js";
34
35
  import { ProfileRegistry, validateProfileId } from "../src/config/ProfileRegistry.js";
35
36
  import { AuthenticatedClient } from "../src/auth/AuthenticatedClient.js";
36
37
  import { BlogClient, BlogApiError } from "../src/api/BlogClient.js";
38
+ import { OrgClient } from "../src/api/OrgClient.js";
39
+ import { CollaborationClient } from "../src/api/CollaborationClient.js";
40
+ import { AnalyticsClient } from "../src/api/AnalyticsClient.js";
41
+ import { EXIT_CODES, errorEnvelope, normalizeCommand } from "../src/cli/contract.js";
42
+ import { colorEnabled, listenForEnter, loginChallenge, successLine } from "../src/cli/ui.js";
37
43
  import {
38
44
  blogCreate,
39
45
  blogDelete,
@@ -44,6 +50,24 @@ import {
44
50
  blogRestore,
45
51
  blogUnpublish,
46
52
  } from "../src/commands/blog/index.js";
53
+ import {
54
+ orgCollections,
55
+ orgGet,
56
+ orgList,
57
+ orgMembers,
58
+ orgTargets,
59
+ } from "../src/commands/org/index.js";
60
+ import {
61
+ collabAccept,
62
+ collabDecline,
63
+ collabInvitations,
64
+ collabInvite,
65
+ collabList,
66
+ collabRemove,
67
+ collabRole,
68
+ } from "../src/commands/collab/index.js";
69
+ import { skillInspect, skillInstall, skillList } from "../src/commands/skill/index.js";
70
+ import { analyticsExport, analyticsQuery } from "../src/commands/analytics/index.js";
47
71
 
48
72
  const OPTIONS = {
49
73
  profile: { type: "string" },
@@ -62,6 +86,12 @@ const OPTIONS = {
62
86
  status: { type: "string" },
63
87
  limit: { type: "string" },
64
88
  cursor: { type: "string" },
89
+ range: { type: "string" },
90
+ from: { type: "string" },
91
+ to: { type: "string" },
92
+ dimension: { type: "string" },
93
+ format: { type: "string" },
94
+ output: { type: "string" },
65
95
  file: { type: "string" },
66
96
  stdin: { type: "boolean", default: false },
67
97
  content: { type: "string" },
@@ -83,12 +113,23 @@ const OPTIONS = {
83
113
  etag: { type: "string" },
84
114
  permanent: { type: "boolean", default: false },
85
115
  "idempotency-key": { type: "string" },
116
+ user: { type: "string" },
117
+ role: { type: "string" },
118
+ "hide-on-profile": { type: "boolean", default: false },
119
+ target: { type: "string" },
120
+ force: { type: "boolean", default: false },
86
121
  help: { type: "boolean", short: "h", default: false },
87
122
  };
88
123
 
89
124
  const HELP_TEXT = `lixblogs — LixBlogs CLI
90
125
 
91
126
  Usage:
127
+ lixblogs login [--profile <name>] [--open]
128
+ lixblogs register [--profile <name>] [--open]
129
+ lixblogs logout [--profile <name>]
130
+ lixblogs whoami [--profile <name>] [--json]
131
+ lixblogs profiles [--json]
132
+ lixblogs use <name> [--json]
92
133
  lixblogs auth login [--profile <name>] [--env <environment>] [--json] [--quiet] [--allow-insecure-fallback]
93
134
  lixblogs auth status [--profile <name>] [--json]
94
135
  lixblogs auth logout [--profile <name>] [--json] [--quiet]
@@ -97,15 +138,34 @@ Usage:
97
138
  lixblogs auth use <name> [--json]
98
139
  lixblogs blog list [--status <status>] [--limit <n>] [--cursor <cursor>] [--json]
99
140
  lixblogs blog get <id> [--json]
141
+ lixblogs blog preview <id> [--json]
100
142
  lixblogs blog create [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
101
143
  lixblogs blog edit <id> [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
102
- lixblogs blog publish <id> [--dry-run] [--json]
103
- lixblogs blog unpublish <id> [--dry-run] [--json]
144
+ lixblogs blog publish <id> --yes [--dry-run] [--json]
145
+ lixblogs blog unpublish <id> --yes [--dry-run] [--json]
104
146
  lixblogs blog delete <id> --yes [--permanent] [--dry-run] [--json]
105
- lixblogs blog restore <id> [--dry-run] [--json]
147
+ lixblogs blog trash <id> --yes [--dry-run] [--json]
148
+ lixblogs blog restore <id> --yes [--dry-run] [--json]
149
+ lixblogs org list [--json]
150
+ lixblogs org get <id> [--json]
151
+ lixblogs org collections <id> [--json]
152
+ lixblogs org members <id> [--json]
153
+ lixblogs org targets [--json]
154
+ lixblogs collab list <blog-id> [--json]
155
+ lixblogs collab invitations [--json]
156
+ lixblogs collab invite <blog-id> --user <username> --role <viewer|editor|admin> --yes
157
+ lixblogs collab role <blog-id> --user <username-or-id> --role <viewer|editor|admin> --yes
158
+ lixblogs collab remove <blog-id> [--user <username-or-id>] --yes
159
+ lixblogs collab accept <blog-id> --yes [--hide-on-profile]
160
+ lixblogs collab decline <blog-id> --yes
161
+ lixblogs analytics query [--scope personal|org:<id>] [--range 30d] [--dimension overview]
162
+ lixblogs analytics export --output <file> [--format json|csv] [query options]
163
+ lixblogs skill list [--json]
164
+ lixblogs skill inspect <name> [--json]
165
+ lixblogs skill install <name> [--target <directory>] [--dry-run] --yes
106
166
 
107
167
  Global flags:
108
- --profile <name> named profile to use (default: "default")
168
+ --profile <name> local account alias (defaults to the signed-in username)
109
169
  --env <environment> override environment (development|staging|production)
110
170
  --auth-provider <provider> elixpo, or mock in development/test only
111
171
  --accounts-url <url> override the Accounts discovery origin
@@ -121,7 +181,7 @@ Global flags:
121
181
  --collection <id> organization collection ID
122
182
  --dry-run validate and show the intended action without writing
123
183
  --permanent permanently delete instead of moving to trash
124
- --open open the complete device verification URL
184
+ --open open the device verification URL immediately
125
185
  --json machine-readable JSON output
126
186
  --quiet suppress non-essential output
127
187
  --yes, -y auto-confirm destructive actions (required for revoke)
@@ -129,8 +189,9 @@ Global flags:
129
189
  non-persistent in-memory store instead of failing
130
190
  --help, -h show this help
131
191
 
132
- Note: interactive confirmation prompting is not implemented yet (CLI-shell/UX
133
- work, a later issue) destructive actions require --yes explicitly, always.
192
+ Machine mode:
193
+ --json --no-input produces stable JSON on stdout, diagnostics on stderr, and
194
+ never prompts. Publishing and destructive state changes require --yes.
134
195
  `;
135
196
 
136
197
  const DEFAULT_SCOPES = [
@@ -169,14 +230,18 @@ function output(opts, data) {
169
230
  }
170
231
  }
171
232
 
172
- function fail(opts, message, exitCode = 1) {
173
- const safeMessage = redactErrorMessage(message);
233
+ function fail(opts, error, exitCode = EXIT_CODES.ERROR) {
234
+ const value = error && typeof error === 'object' ? error : { message: String(error) };
235
+ const safeMessage = redactErrorMessage(value.message);
236
+ const envelope = errorEnvelope({ ...value, message: safeMessage });
174
237
  if (opts.json) {
175
- process.stdout.write(safeJsonStringify({ ok: false, error: safeMessage }) + "\n");
238
+ process.stdout.write(safeJsonStringify(envelope) + "\n");
176
239
  } else if (!opts.quiet) {
177
240
  process.stderr.write(`Error: ${safeMessage}\n`);
241
+ if (value.hint) process.stderr.write(`Hint: ${value.hint}\n`);
242
+ if (value.requestId) process.stderr.write(`Request: ${value.requestId}\n`);
178
243
  }
179
- process.exitCode = exitCode;
244
+ process.exitCode = value.exitCode || exitCode;
180
245
  }
181
246
 
182
247
  /**
@@ -205,7 +270,11 @@ async function getCredentialStoreOrFail(opts, profileRegistry) {
205
270
  async function runLogin(opts) {
206
271
  const config = resolveConfig({ flags: configFlags(opts) });
207
272
  const profileRegistry = new ProfileRegistry();
208
- const profileId = await selectedProfile(config, profileRegistry);
273
+ const requestedProfileId = validateProfileId(config.profile);
274
+ const scopes = opts.scope?.length ? [...opts.scope] : [...DEFAULT_SCOPES];
275
+ if (!config.profileExplicit && !scopes.includes("lixblogs:profile:read")) {
276
+ scopes.push("lixblogs:profile:read");
277
+ }
209
278
 
210
279
  let provider;
211
280
  try {
@@ -217,35 +286,53 @@ async function runLogin(opts) {
217
286
  const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
218
287
  if (!credentialStore) return;
219
288
 
220
- const result = await authLogin({
221
- provider,
222
- credentialStore,
223
- profileId,
224
- scopes: opts.scope?.length ? opts.scope : DEFAULT_SCOPES,
225
- openBrowser: opts.open ? openBrowser : undefined,
226
- onStatus: (status) => {
227
- if (opts.json) {
228
- output(opts, { event: status.type, ...status });
229
- return;
230
- }
231
- if (opts.quiet) return;
232
- if (status.type === "verification_pending") {
233
- console.log(`To log in, visit: ${status.verificationUriComplete || status.verificationUri}`);
234
- console.log(`Enter code: ${status.userCode}`);
235
- console.log(`(expires in ${status.expiresInSeconds}s)`);
236
- } else if (status.type === "pending") {
237
- console.log("Waiting for approval...");
238
- } else if (status.type === "slow_down") {
239
- console.log("Slowing down polling as requested by the server...");
240
- } else if (status.type === "approved") {
241
- console.log("Login approved.");
242
- } else if (status.type === "denied") {
243
- console.log("Login was denied.");
244
- } else if (status.type === "expired") {
245
- console.log("Device code expired.");
246
- }
247
- },
248
- });
289
+ let stopEnterListener = () => {};
290
+ let result;
291
+ try {
292
+ result = await authLogin({
293
+ provider,
294
+ credentialStore,
295
+ profileId: requestedProfileId,
296
+ scopes,
297
+ openBrowser: opts.open ? openBrowser : undefined,
298
+ resolveProfileId: config.profileExplicit
299
+ ? undefined
300
+ : ({ accessToken }) => profileAliasFromIdentity({
301
+ accessToken,
302
+ apiBaseUrl: config.apiBaseUrl,
303
+ }),
304
+ onStatus: (status) => {
305
+ if (opts.json) {
306
+ if (status.type !== "pending") output(opts, { event: status.type, ...status });
307
+ return;
308
+ }
309
+ if (opts.quiet) return;
310
+ if (status.type === "verification_pending") {
311
+ const url = status.verificationUriComplete || status.verificationUri;
312
+ const interactive = Boolean(process.stdin.isTTY) && !opts["no-input"];
313
+ process.stdout.write(loginChallenge({
314
+ url,
315
+ code: status.userCode,
316
+ expiresInSeconds: status.expiresInSeconds,
317
+ profile: config.profileExplicit ? requestedProfileId : null,
318
+ interactive,
319
+ color: colorEnabled(),
320
+ }));
321
+ if (interactive && !opts.open) {
322
+ stopEnterListener = listenForEnter({ input: process.stdin, open: openBrowser, url });
323
+ }
324
+ } else if (status.type === "approved") {
325
+ console.log(successLine("Access approved by Elixpo Accounts.", colorEnabled()));
326
+ } else if (status.type === "denied") {
327
+ console.log(" Access denied.");
328
+ } else if (status.type === "expired") {
329
+ console.log(" Device code expired.");
330
+ }
331
+ },
332
+ });
333
+ } finally {
334
+ stopEnterListener();
335
+ }
249
336
 
250
337
  if (!result.ok) {
251
338
  return fail(opts, result.reason);
@@ -254,7 +341,9 @@ async function runLogin(opts) {
254
341
  await profileRegistry.setActive(result.profileId);
255
342
  output(opts, { ok: true, profile: result.profileId });
256
343
  if (!opts.json && !opts.quiet) {
257
- console.log(`Logged in as profile "${result.profileId}".`);
344
+ console.log(` Credentials saved to local profile "${result.profileId}".`);
345
+ console.log(" Tip: add another account with `lixblogs login`, list accounts with `lixblogs profiles`,");
346
+ console.log(" and switch with `lixblogs use <username>`.");
258
347
  }
259
348
  }
260
349
 
@@ -281,6 +370,60 @@ async function runStatus(opts) {
281
370
  }
282
371
  }
283
372
 
373
+ async function authenticatedBlogClient(opts) {
374
+ const config = resolveConfig({ flags: configFlags(opts) });
375
+ const profileRegistry = new ProfileRegistry();
376
+ const profileId = await selectedProfile(config, profileRegistry);
377
+ const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
378
+ if (!credentialStore) return null;
379
+ let provider;
380
+ try { provider = createAuthProvider(config); } catch (error) { fail(opts, error); return null; }
381
+ const http = new AuthenticatedClient({ provider, credentialStore, profileId, apiBaseUrl: config.apiBaseUrl });
382
+ return { client: new BlogClient(http), http, config, credentialStore, profileId };
383
+ }
384
+
385
+ async function runWhoami(opts) {
386
+ const context = await authenticatedBlogClient(opts);
387
+ if (!context) return;
388
+ try {
389
+ const [identity, credentials] = await Promise.all([
390
+ context.client.whoami(),
391
+ context.credentialStore.get(context.profileId),
392
+ ]);
393
+ const result = {
394
+ ok: true,
395
+ profile: context.profileId,
396
+ environment: context.config.environment,
397
+ identity,
398
+ scopes: credentials?.scopes || [],
399
+ expiresAt: credentials?.expiresAt ? new Date(credentials.expiresAt).toISOString() : null,
400
+ expired: credentials ? Date.now() >= credentials.expiresAt : true,
401
+ };
402
+ output(opts, result);
403
+ if (!opts.json && !opts.quiet) {
404
+ console.log(`${identity.displayName || identity.username} (@${identity.username})`);
405
+ console.log(`Profile: ${context.profileId} · ${result.environment}`);
406
+ console.log(`Scopes: ${result.scopes.join(', ') || 'none'}`);
407
+ console.log(`Expires: ${result.expiresAt || 'unknown'}`);
408
+ }
409
+ } catch (error) {
410
+ fail(opts, error, error.status === 401 || error.status === 403 ? EXIT_CODES.AUTH : EXIT_CODES.ERROR);
411
+ }
412
+ }
413
+
414
+ async function runRegister(opts) {
415
+ const config = resolveConfig({ flags: configFlags(opts) });
416
+ const registrationUrl = new URL('/register', config.accountsBaseUrl).toString();
417
+ if (opts['no-input']) {
418
+ output(opts, { ok: true, registrationUrl, next: 'lixblogs login' });
419
+ if (!opts.json && !opts.quiet) console.log(registrationUrl);
420
+ return;
421
+ }
422
+ await openBrowser(registrationUrl);
423
+ if (!opts.quiet) console.log(`Create your account at ${registrationUrl}, then approve the device login.`);
424
+ await runLogin(opts);
425
+ }
426
+
284
427
  async function runLogout(opts) {
285
428
  const config = resolveConfig({ flags: configFlags(opts) });
286
429
  const profileRegistry = new ProfileRegistry();
@@ -370,14 +513,45 @@ async function runUse(opts, args) {
370
513
  const BLOG_COMMANDS = {
371
514
  list: blogList,
372
515
  get: blogGet,
516
+ preview: blogGet,
373
517
  create: blogCreate,
374
518
  edit: blogEdit,
375
519
  publish: blogPublish,
376
520
  unpublish: blogUnpublish,
377
521
  delete: blogDelete,
522
+ trash: blogDelete,
378
523
  restore: blogRestore,
379
524
  };
380
525
 
526
+ const ORG_COMMANDS = {
527
+ list: orgList,
528
+ get: orgGet,
529
+ collections: orgCollections,
530
+ members: orgMembers,
531
+ targets: orgTargets,
532
+ };
533
+
534
+ const COLLAB_COMMANDS = {
535
+ list: collabList,
536
+ invitations: collabInvitations,
537
+ invite: collabInvite,
538
+ role: collabRole,
539
+ remove: collabRemove,
540
+ accept: collabAccept,
541
+ decline: collabDecline,
542
+ };
543
+
544
+ const SKILL_COMMANDS = {
545
+ list: ({ options }) => skillList(options),
546
+ inspect: ({ id }) => skillInspect({ name: id }),
547
+ install: ({ id, options }) => skillInstall({ name: id, options }),
548
+ };
549
+
550
+ const ANALYTICS_COMMANDS = {
551
+ query: analyticsQuery,
552
+ export: analyticsExport,
553
+ };
554
+
381
555
  async function runBlog(opts, args, action) {
382
556
  const config = resolveConfig({ flags: configFlags(opts) });
383
557
  const profileRegistry = new ProfileRegistry();
@@ -420,7 +594,122 @@ async function runBlog(opts, args, action) {
420
594
  process.exitCode = error.status === 412 ? 3 : 1;
421
595
  return;
422
596
  }
423
- return fail(opts, `${error.message}${error.requestId ? ` (request ${error.requestId})` : ''}`, error.status === 412 ? 3 : 1);
597
+ return fail(opts, error, error.status === 412 ? EXIT_CODES.CONFLICT : EXIT_CODES.ERROR);
598
+ }
599
+ }
600
+
601
+ async function runOrg(opts, args, action) {
602
+ const context = await authenticatedBlogClient(opts);
603
+ if (!context) return;
604
+ const client = new OrgClient(context.http);
605
+ try {
606
+ const result = await ORG_COMMANDS[action]({ client, id: args[0], options: opts });
607
+ output(opts, { ok: true, data: result });
608
+ if (opts.json || opts.quiet) return;
609
+ if (action === 'targets') {
610
+ console.log('personal\tPersonal Blog');
611
+ for (const org of result.organizations || []) {
612
+ console.log(`${org.target}\t${org.role}\t${org.name}`);
613
+ for (const collection of org.collections || []) {
614
+ console.log(` collection:${collection.id}\t${collection.name}`);
615
+ }
616
+ }
617
+ return;
618
+ }
619
+ const rows = action === 'list' ? result.data || [] : Array.isArray(result) ? result : [result];
620
+ for (const row of rows) {
621
+ console.log([
622
+ row.id || row.userId || row.orgId,
623
+ row.role,
624
+ row.slug || row.username,
625
+ row.name || row.displayName,
626
+ ].filter(Boolean).join('\t'));
627
+ }
628
+ } catch (error) {
629
+ fail(opts, error, error.status === 401 || error.status === 403 ? EXIT_CODES.AUTH : EXIT_CODES.ERROR);
630
+ }
631
+ }
632
+
633
+ async function runCollab(opts, args, action) {
634
+ const context = await authenticatedBlogClient(opts);
635
+ if (!context) return;
636
+ const client = new CollaborationClient(context.http);
637
+ try {
638
+ const result = await COLLAB_COMMANDS[action]({ client, id: args[0], options: opts });
639
+ output(opts, { ok: true, data: result });
640
+ if (opts.json || opts.quiet) return;
641
+ if (result.dryRun) {
642
+ console.log(`Dry run: ${result.action} validated; no changes sent.`);
643
+ return;
644
+ }
645
+ const rows = action === 'invitations'
646
+ ? result
647
+ : action === 'list'
648
+ ? result.collaborators || []
649
+ : [result];
650
+ for (const row of rows) {
651
+ console.log([
652
+ row.blogId || row.userId,
653
+ row.status,
654
+ row.role,
655
+ row.username || row.title,
656
+ row.notificationState,
657
+ ].filter(Boolean).join('\t'));
658
+ }
659
+ } catch (error) {
660
+ fail(opts, error, error.status === 401 || error.status === 403 ? EXIT_CODES.AUTH : EXIT_CODES.ERROR);
661
+ }
662
+ }
663
+
664
+ async function runSkill(opts, args, action) {
665
+ try {
666
+ const result = await SKILL_COMMANDS[action]({ id: args[0], options: opts });
667
+ output(opts, { ok: true, data: result });
668
+ if (opts.json || opts.quiet) return;
669
+ if (action === 'list') {
670
+ for (const skill of result) console.log(`${skill.name}\tCLI >= ${skill.minimumCliVersion || 'unknown'}\t${skill.description}`);
671
+ } else if (action === 'inspect') {
672
+ process.stdout.write(result.content);
673
+ } else if (result.dryRun) {
674
+ console.log(`Dry run: install ${result.name} to ${result.target}${result.replace ? ' (replace)' : ''}.`);
675
+ } else {
676
+ console.log(`Installed ${result.name} at ${result.target}.`);
677
+ }
678
+ } catch (error) {
679
+ fail(opts, error);
680
+ }
681
+ }
682
+
683
+ async function runAnalytics(opts, _args, action) {
684
+ const context = await authenticatedBlogClient(opts);
685
+ if (!context) return;
686
+ const client = new AnalyticsClient(context.http);
687
+ const normalized = {
688
+ ...opts,
689
+ limit: opts.limit === undefined ? undefined : Number.parseInt(opts.limit, 10),
690
+ };
691
+ try {
692
+ const result = await ANALYTICS_COMMANDS[action]({ client, options: normalized });
693
+ output(opts, { ok: true, data: result });
694
+ if (opts.json || opts.quiet) return;
695
+ if (action === 'export') {
696
+ console.log(`Exported ${result.rows} rows to ${result.output}.`);
697
+ return;
698
+ }
699
+ const payload = result.data;
700
+ console.log(`${payload.scope.label} · ${payload.dimension} · ${payload.range.key}`);
701
+ if (payload.dimension === 'overview') {
702
+ for (const [metric, value] of Object.entries(payload.values.totals)) {
703
+ console.log(`${metric}\t${value}\t${payload.values.changes[metric]}%`);
704
+ }
705
+ } else if (payload.dimension === 'timeline') {
706
+ payload.values.labels.forEach((label, index) => console.log(`${label}\t${payload.values.views[index]}\t${payload.values.reads[index]}`));
707
+ } else {
708
+ for (const row of payload.values) console.log(Object.values(row).join('\t'));
709
+ if (result.meta?.nextCursor) console.log(`Next cursor: ${result.meta.nextCursor}`);
710
+ }
711
+ } catch (error) {
712
+ fail(opts, error, error.status === 401 || error.status === 403 ? EXIT_CODES.AUTH : EXIT_CODES.ERROR);
424
713
  }
425
714
  }
426
715
 
@@ -428,6 +717,7 @@ const ROUTES = {
428
717
  auth: {
429
718
  login: runLogin,
430
719
  status: runStatus,
720
+ whoami: runWhoami,
431
721
  logout: runLogout,
432
722
  revoke: runRevoke,
433
723
  profiles: runProfiles,
@@ -437,6 +727,22 @@ const ROUTES = {
437
727
  action,
438
728
  (opts, args) => runBlog(opts, args, action),
439
729
  ])),
730
+ org: Object.fromEntries(Object.keys(ORG_COMMANDS).map((action) => [
731
+ action,
732
+ (opts, args) => runOrg(opts, args, action),
733
+ ])),
734
+ collab: Object.fromEntries(Object.keys(COLLAB_COMMANDS).map((action) => [
735
+ action,
736
+ (opts, args) => runCollab(opts, args, action),
737
+ ])),
738
+ skill: Object.fromEntries(Object.keys(SKILL_COMMANDS).map((action) => [
739
+ action,
740
+ (opts, args) => runSkill(opts, args, action),
741
+ ])),
742
+ analytics: Object.fromEntries(Object.keys(ANALYTICS_COMMANDS).map((action) => [
743
+ action,
744
+ (opts, args) => runAnalytics(opts, args, action),
745
+ ])),
440
746
  };
441
747
 
442
748
  async function main() {
@@ -453,7 +759,7 @@ async function main() {
453
759
  // unrecognized flags rather than silently ignoring them — surface that
454
760
  // clearly instead of an unhandled exception.
455
761
  process.stderr.write(`Error: Invalid flag. ${err.message}\n`);
456
- process.exitCode = 1;
762
+ process.exitCode = EXIT_CODES.USAGE;
457
763
  return;
458
764
  }
459
765
 
@@ -462,13 +768,19 @@ async function main() {
462
768
  return;
463
769
  }
464
770
 
771
+ if (positionals[0] === 'register') {
772
+ await runRegister(values);
773
+ return;
774
+ }
775
+
776
+ positionals = normalizeCommand(positionals);
465
777
  const [category, action] = positionals;
466
778
  const categoryRoutes = ROUTES[category];
467
779
 
468
780
  if (!categoryRoutes) {
469
781
  process.stderr.write(`Error: Unknown command category "${category}".\n`);
470
782
  process.stderr.write(`Available categories: ${Object.keys(ROUTES).join(", ")}\n`);
471
- process.exitCode = 1;
783
+ process.exitCode = EXIT_CODES.USAGE;
472
784
  return;
473
785
  }
474
786
 
@@ -480,7 +792,7 @@ async function main() {
480
792
  .map((a) => `${category} ${a}`)
481
793
  .join(", ")}\n`
482
794
  );
483
- process.exitCode = 1;
795
+ process.exitCode = EXIT_CODES.USAGE;
484
796
  return;
485
797
  }
486
798
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixpo/lixblogs-cli",
3
- "version": "1.1.2",
3
+ "version": "1.3.3",
4
4
  "description": "Official CLI for LixBlogs — publish, manage, and inspect blogs through the supported API. Built for creators and agent automation.",
5
5
  "author": {
6
6
  "name": "Elixpo",
@@ -20,12 +20,18 @@
20
20
  "files": [
21
21
  "src",
22
22
  "bin",
23
+ "skills",
23
24
  "README.md",
24
25
  "API.md",
25
- "THREAT_MODEL.md"
26
+ "THREAT_MODEL.md",
27
+ "RELEASE.md",
28
+ "CHANGELOG.md"
26
29
  ],
27
30
  "scripts": {
28
31
  "test": "node --test tests/*.test.mjs",
32
+ "skills:sync": "node scripts/sync-skills.mjs",
33
+ "prepack": "npm run skills:sync && npm test",
34
+ "postpack": "node scripts/sync-skills.mjs --clean",
29
35
  "prepublishOnly": "npm test"
30
36
  },
31
37
  "engines": {
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: lixblogs-analytics
3
+ description: Retrieve and explain aggregate LixBlogs creator analytics through the supported CLI. Use when an agent needs personal or authorized organization performance for an explicit date range, a bounded dimension, or a JSON/CSV export without changing content or exposing visitor identifiers.
4
+ ---
5
+
6
+ # LixBlogs analytics
7
+
8
+ Use `@elixpo/lixblogs-cli` 1.3.0 or newer with `--json --no-input`. Use only CLI commands; never query D1, inspect credentials, or call analytics endpoints directly.
9
+
10
+ ## Access
11
+
12
+ Personal analytics requires `lixblogs:analytics:read`. Organization analytics also requires `lixblogs:organizations:read` and a current owner, admin, or maintain role.
13
+
14
+ ```bash
15
+ lixblogs whoami --json --no-input
16
+ lixblogs analytics query --scope personal --range 30d --dimension overview --json --no-input
17
+ ```
18
+
19
+ Use `lixblogs org list --json --no-input` before querying `--scope org:ORG_ID`. Never infer organization access from a public page or slug.
20
+
21
+ ## Query
22
+
23
+ Choose one range and one dimension per request. Supported ranges are `7d`, `30d`, `90d`, `12m`, or `custom`; custom requires both ISO dates. Supported dimensions are `overview`, `timeline`, `posts`, `sources`, `devices`, and `countries`.
24
+
25
+ ```bash
26
+ lixblogs analytics query --range custom --from 2026-07-01 --to 2026-07-31 \
27
+ --dimension posts --limit 25 --json --no-input
28
+ ```
29
+
30
+ Follow `meta.nextCursor` until null when all pages are required. Keep `--limit` at 100 or below. Treat an empty `values` array as valid data, not a failure.
31
+
32
+ ## Explain results
33
+
34
+ - State the scope and date range first.
35
+ - Report returned facts separately from interpretations.
36
+ - Use the returned metric definitions; do not redefine a metric.
37
+ - Call out a zero previous value when describing percentage changes.
38
+ - Do not rank creators, infer sensitive traits, or attempt to identify visitors.
39
+ - Say when a collection window is incomplete or the dataset is empty.
40
+
41
+ ## Export
42
+
43
+ ```bash
44
+ lixblogs analytics export --scope personal --range 30d --dimension timeline \
45
+ --format csv --output analytics.csv --json --no-input
46
+ ```
47
+
48
+ Exports support JSON and CSV and refuse to overwrite an existing path. Choose a new path instead of deleting or replacing a file.
49
+
50
+ ## Recovery
51
+
52
+ - `insufficient_scope`: log in again with only the missing read scope.
53
+ - `forbidden_scope`: stop; do not probe another organization identifier.
54
+ - `invalid_request`, `invalid_limit`, or `invalid_cursor`: correct the explicit query and retry once.
55
+ - `rate_limit_exceeded`: honor the server retry window.
56
+ - Preserve request IDs for diagnosis without printing tokens or credentials.
57
+
58
+ This skill performs no blog, collaborator, organization, or account mutation.
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "LixBlogs Analytics"
3
+ short_description: "Read bounded creator performance data"
4
+ brand_color: "#8B5CF6"
5
+ default_prompt: "Use $lixblogs-analytics to retrieve and explain creator analytics without making changes."
6
+ policy:
7
+ allow_implicit_invocation: true