@distributionos/cli 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DistributionOS CLI
2
2
 
3
- Local installer for connecting an app repo to DistributionOS.
3
+ Installer for connecting an app repo to DistributionOS.
4
4
 
5
5
  ```bash
6
6
  npx @distributionos/cli setup --app <appId>
@@ -8,11 +8,11 @@ npx @distributionos/cli setup --app <appId>
8
8
 
9
9
  Default setup opens the DistributionOS MCP OAuth approval flow when needed, then prints a plan first. In an interactive terminal, approve the plan to apply the managed setup changes. In non-interactive agent runs, use `--apply` only after reviewing the dry-run output.
10
10
 
11
- Before the package is published, test the local package from another repo with:
12
-
13
- ```bash
14
- npm exec --yes --package "C:\Users\lawre\DistributionOS\packages\cli" -- distributionos setup --app <appId> --api-base http://localhost:3005
15
- ```
11
+ Agents that support installable skills can also load the public DistributionOS agent skill before using the CLI:
12
+
13
+ https://github.com/lawfan1026/distributionos-agent-skill
14
+
15
+ The CLI remains the default setup path. The skill is a public agent-readable guide for when to use the CLI, MCP, API fallback, analytics install, verification, and implementation reporting.
16
16
 
17
17
  ## Commands
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@distributionos/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DistributionOS repo setup CLI for agent onboarding and first-party analytics.",
5
5
  "homepage": "https://distributionos.dev",
6
6
  "repository": {
package/src/api.js CHANGED
@@ -1,12 +1,21 @@
1
1
  import { DEFAULT_API_BASE, TOKEN_ENV_NAMES } from './constants.js';
2
- import { getStoredCredential } from './auth-store.js';
3
- import { isCredentialFresh, refreshOAuthCredential } from './oauth.js';
4
-
5
- export function resolveEnvToken(env) {
6
- for (const name of TOKEN_ENV_NAMES) {
7
- const value = env?.[name]?.trim();
8
- if (value) return { source: 'env', name, value };
9
- }
2
+ import { getStoredCredential } from './auth-store.js';
3
+ import { isCredentialFresh, refreshOAuthCredential } from './oauth.js';
4
+
5
+ export class DistributionOsApiError extends Error {
6
+ constructor(message, { status = 0, code = null } = {}) {
7
+ super(message);
8
+ this.name = 'DistributionOsApiError';
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+
14
+ export function resolveEnvToken(env) {
15
+ for (const name of TOKEN_ENV_NAMES) {
16
+ const value = env?.[name]?.trim();
17
+ if (value) return { source: 'env', name, value };
18
+ }
10
19
  return null;
11
20
  }
12
21
 
@@ -39,48 +48,69 @@ export async function fetchDistributionOsSetupContext(input) {
39
48
  apiBase: input.apiBase ?? DEFAULT_API_BASE,
40
49
  appId: input.appId,
41
50
  fetchImpl: input.fetch,
42
- });
43
- if (!token || input.noFetch) {
44
- return {
45
- status: 'not_authenticated',
46
- tokenName: token?.name ?? null,
47
- instructions: null,
48
- analyticsContract: null,
49
- errors: [],
50
- };
51
- }
52
-
53
- const apiBase = normalizeApiBase(input.apiBase ?? DEFAULT_API_BASE);
54
- const errors = [];
55
- const [instructions, analyticsContract] = await Promise.all([
56
- fetchJson({
57
- fetchImpl: input.fetch,
58
- apiBase,
59
- token: token.value,
60
- path: `/api/v1/apps/${encodeURIComponent(input.appId)}/agent/instructions`,
61
- }).catch(error => {
62
- errors.push(`Agent instructions fetch failed: ${error.message}`);
63
- return null;
64
- }),
65
- fetchJson({
66
- fetchImpl: input.fetch,
67
- apiBase,
68
- token: token.value,
69
- path: `/api/v1/apps/${encodeURIComponent(input.appId)}/analytics/contract`,
70
- }).catch(error => {
71
- errors.push(`Analytics contract fetch failed: ${error.message}`);
72
- return null;
73
- }),
74
- ]);
75
-
76
- return {
77
- status: errors.length === 0 ? 'fetched' : 'partial',
78
- tokenName: token.name,
79
- instructions,
80
- analyticsContract,
81
- errors,
82
- };
83
- }
51
+ });
52
+ if (!token || input.noFetch) {
53
+ return {
54
+ status: input.noFetch ? 'skipped' : 'not_authenticated',
55
+ tokenName: token?.name ?? null,
56
+ access: {
57
+ allowed: false,
58
+ reason: input.noFetch ? 'remote_fetch_skipped' : 'not_authenticated',
59
+ billingUrl: `${normalizeApiBase(input.apiBase ?? DEFAULT_API_BASE)}/dashboard/account/billing`,
60
+ },
61
+ usage: null,
62
+ instructions: null,
63
+ analyticsContract: null,
64
+ errors: [],
65
+ };
66
+ }
67
+
68
+ const apiBase = normalizeApiBase(input.apiBase ?? DEFAULT_API_BASE);
69
+ const errors = [];
70
+ let subscriptionRequired = false;
71
+ const capture = (label) => (error) => {
72
+ if (error instanceof DistributionOsApiError && (error.status === 402 || error.code === 'SUBSCRIPTION_REQUIRED')) {
73
+ subscriptionRequired = true;
74
+ }
75
+ errors.push(`${label} failed: ${error.message}`);
76
+ return null;
77
+ };
78
+
79
+ const [instructions, analyticsContract, usage] = await Promise.all([
80
+ fetchJson({
81
+ fetchImpl: input.fetch,
82
+ apiBase,
83
+ token: token.value,
84
+ path: `/api/v1/apps/${encodeURIComponent(input.appId)}/agent/instructions`,
85
+ }).catch(capture('Agent instructions fetch')),
86
+ fetchJson({
87
+ fetchImpl: input.fetch,
88
+ apiBase,
89
+ token: token.value,
90
+ path: `/api/v1/apps/${encodeURIComponent(input.appId)}/analytics/contract`,
91
+ }).catch(capture('Analytics contract fetch')),
92
+ fetchJson({
93
+ fetchImpl: input.fetch,
94
+ apiBase,
95
+ token: token.value,
96
+ path: `/api/v1/apps/${encodeURIComponent(input.appId)}/usage`,
97
+ }).catch(capture('Usage fetch')),
98
+ ]);
99
+
100
+ return {
101
+ status: errors.length === 0 ? 'fetched' : 'partial',
102
+ tokenName: token.name,
103
+ access: {
104
+ allowed: !subscriptionRequired,
105
+ reason: subscriptionRequired ? 'subscription_required' : errors.length === 0 ? 'active' : 'partial',
106
+ billingUrl: `${apiBase}/dashboard/account/billing`,
107
+ },
108
+ usage,
109
+ instructions,
110
+ analyticsContract,
111
+ errors,
112
+ };
113
+ }
84
114
 
85
115
  export async function ensureAnalyticsSite(input) {
86
116
  const token = await resolveAuthToken({
@@ -198,11 +228,14 @@ async function fetchJson({ fetchImpl, apiBase, token, path }) {
198
228
  },
199
229
  });
200
230
  const body = await response.json().catch(() => null);
201
- if (!response.ok || body?.success === false) {
202
- throw new Error(body?.error || `HTTP ${response.status}`);
203
- }
204
- return body?.data ?? body;
205
- }
231
+ if (!response.ok || body?.success === false) {
232
+ throw new DistributionOsApiError(body?.error || `HTTP ${response.status}`, {
233
+ status: response.status,
234
+ code: body?.code ?? null,
235
+ });
236
+ }
237
+ return body?.data ?? body;
238
+ }
206
239
 
207
240
  function normalizeApiBase(value) {
208
241
  return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
package/src/cli.js CHANGED
@@ -157,14 +157,19 @@ export async function runCli(argv, runtime) {
157
157
  args.apply = true;
158
158
  }
159
159
 
160
- if (args.yes) {
161
- args.apply = true;
162
- }
163
-
164
- if (args.apply && plan.repo.git.dirty && !args.allowDirty) {
165
- stderr.write('Refusing to apply changes to a dirty worktree. Commit/stash changes or pass --allow-dirty.\n');
166
- return 1;
167
- }
160
+ if (args.yes) {
161
+ args.apply = true;
162
+ }
163
+
164
+ if (args.apply && hasRemoteAccessBlock(plan)) {
165
+ stderr.write(formatRemoteAccessBlock(plan));
166
+ return 1;
167
+ }
168
+
169
+ if (args.apply && plan.repo.git.dirty && !args.allowDirty) {
170
+ stderr.write('Refusing to apply changes to a dirty worktree. Commit/stash changes or pass --allow-dirty.\n');
171
+ return 1;
172
+ }
168
173
 
169
174
  if (args.apply && !args.skipAnalytics) {
170
175
  const ensured = await ensureAnalyticsSite({
@@ -230,12 +235,26 @@ export async function runCli(argv, runtime) {
230
235
  }
231
236
  }
232
237
 
233
- function hasBlockingApplyFailure(validation, inspection) {
234
- return validation.some((result) => result.introducedFailure) ||
235
- inspection.some((result) => result.status === 'failed');
236
- }
237
-
238
- export function parseArgs(rawArgs) {
238
+ function hasBlockingApplyFailure(validation, inspection) {
239
+ return validation.some((result) => result.introducedFailure) ||
240
+ inspection.some((result) => result.status === 'failed');
241
+ }
242
+
243
+ function hasRemoteAccessBlock(plan) {
244
+ return plan.remote?.access?.reason === 'subscription_required';
245
+ }
246
+
247
+ function formatRemoteAccessBlock(plan) {
248
+ const billingUrl = plan.remote?.access?.billingUrl ?? `${plan.apiBase ?? DEFAULT_API_BASE}/dashboard/account/billing`;
249
+ return [
250
+ 'DistributionOS setup requires an active Builder or Distributor subscription before applying changes.',
251
+ `Manage billing: ${billingUrl}`,
252
+ 'No files were changed.',
253
+ '',
254
+ ].join('\n');
255
+ }
256
+
257
+ export function parseArgs(rawArgs) {
239
258
  const parsed = {
240
259
  command: null,
241
260
  appId: null,
package/src/constants.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const cliPackageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
+
1
5
  export const CLI_PACKAGE_NAME = '@distributionos/cli';
2
- export const CLI_LOCAL_VERSION = '0.0.0-local';
6
+ export const CLI_LOCAL_VERSION = typeof cliPackageJson.version === 'string' ? cliPackageJson.version : '0.0.0-local';
3
7
  export const DEFAULT_API_BASE = 'https://distributionos.dev';
4
8
  export const MCP_SERVER_URL = 'https://distributionos.dev/api/mcp';
5
- export const CURRENT_BOOTSTRAP_VERSION = '2026.06.13';
6
- export const CURRENT_ANALYTICS_CONTRACT_VERSION = '2026.06.13';
9
+ export const CURRENT_BOOTSTRAP_VERSION = '2026.06.14';
10
+ export const CURRENT_ANALYTICS_CONTRACT_VERSION = '2026.06.14';
7
11
 
8
12
  export const TOKEN_ENV_NAMES = [
9
13
  'DISTRIBUTIONOS_API_KEY',
package/src/oauth.js CHANGED
@@ -27,11 +27,14 @@ export async function loginWithOAuth(options) {
27
27
  state,
28
28
  });
29
29
 
30
- options.stdout?.write([
31
- 'Open this DistributionOS authorization URL:',
32
- authorizeUrl,
33
- '',
34
- ].join('\n'));
30
+ options.stdout?.write([
31
+ 'Open this DistributionOS authorization URL:',
32
+ authorizeUrl,
33
+ '',
34
+ 'Keep this terminal running until the browser says the CLI is connected.',
35
+ 'If the browser opens a failed connection page, press Ctrl+C here and rerun with --no-open, then paste the full printed URL into your browser.',
36
+ '',
37
+ ].join('\n'));
35
38
  if (options.openBrowser !== false) openUrl(authorizeUrl);
36
39
 
37
40
  const authResult = await callback.waitForCallback();
@@ -204,18 +207,28 @@ async function createCallbackServer() {
204
207
  };
205
208
  }
206
209
 
207
- function openUrl(url) {
208
- const command = process.platform === 'win32'
209
- ? 'cmd'
210
- : process.platform === 'darwin'
211
- ? 'open'
212
- : 'xdg-open';
213
- const args = process.platform === 'win32'
214
- ? ['/c', 'start', '""', url]
215
- : [url];
216
- try {
217
- const child = spawn(command, args, {
218
- detached: true,
210
+ export function getOpenUrlCommand(url, platform = process.platform) {
211
+ const command = platform === 'win32'
212
+ ? 'cmd'
213
+ : platform === 'darwin'
214
+ ? 'open'
215
+ : 'xdg-open';
216
+ const args = platform === 'win32'
217
+ ? ['/c', 'start', '""', quoteWindowsStartUrl(url)]
218
+ : [url];
219
+ return { command, args };
220
+ }
221
+
222
+ function quoteWindowsStartUrl(url) {
223
+ const safeUrl = String(url).replace(/"/g, '%22');
224
+ return `"${safeUrl}"`;
225
+ }
226
+
227
+ function openUrl(url) {
228
+ const { command, args } = getOpenUrlCommand(url);
229
+ try {
230
+ const child = spawn(command, args, {
231
+ detached: true,
219
232
  stdio: 'ignore',
220
233
  windowsHide: true,
221
234
  });
package/src/plan.js CHANGED
@@ -106,11 +106,12 @@ export function formatPlanText(plan) {
106
106
  `- Layout candidates: ${formatList(plan.repo.layoutCandidates)}`,
107
107
  `- Content files found: ${plan.repo.contentFiles.length}`,
108
108
  `- Deploy hints: ${formatList(plan.repo.deployHints)}`,
109
- '',
110
- 'DistributionOS access',
111
- remoteSummary(plan),
112
- ...plan.remote.errors.map(error => `- ${error}`),
113
- '',
109
+ '',
110
+ 'DistributionOS access',
111
+ remoteSummary(plan),
112
+ ...usageSummary(plan.remote.usage),
113
+ ...plan.remote.errors.map(error => `- ${error}`),
114
+ '',
114
115
  'Planned changes',
115
116
  `1. Agent bootstrap: ${plan.bootstrap.action} ${plan.bootstrap.target}`,
116
117
  ` ${plan.bootstrap.reason}`,
@@ -209,11 +210,14 @@ function selectAnalyticsLayoutTarget(repo) {
209
210
  return candidates[0] ?? null;
210
211
  }
211
212
 
212
- function buildWarnings({ repo, remote }) {
213
- const warnings = [];
214
- if (repo.git.dirty) {
215
- warnings.push('Worktree has existing changes. Keep baseline failures separate before applying edits.');
216
- }
213
+ function buildWarnings({ repo, remote }) {
214
+ const warnings = [];
215
+ if (remote.access?.reason === 'subscription_required') {
216
+ warnings.push('Active Builder or Distributor subscription required before applying setup.');
217
+ }
218
+ if (repo.git.dirty) {
219
+ warnings.push('Worktree has existing changes. Keep baseline failures separate before applying edits.');
220
+ }
217
221
  if (!remote.instructions) {
218
222
  warnings.push('Live DistributionOS instructions were not fetched. Connect MCP OAuth or provide a safe env token before applying app-specific setup.');
219
223
  }
@@ -237,15 +241,36 @@ function buildValidationPlan(repo) {
237
241
  };
238
242
  }
239
243
 
240
- function remoteSummary(plan) {
241
- if (plan.remote.status === 'fetched') {
242
- return `- Fetched live instructions and analytics contract using ${plan.remote.tokenName}.`;
243
- }
244
- if (plan.remote.status === 'partial') {
245
- return `- Used ${plan.remote.tokenName}, but one or more DistributionOS requests failed.`;
246
- }
247
- return '- No env token found. Dry-run used local fallback instructions and did not call DistributionOS APIs.';
248
- }
244
+ function remoteSummary(plan) {
245
+ if (plan.remote.status === 'fetched') {
246
+ return `- Fetched live instructions and analytics contract using ${plan.remote.tokenName}.`;
247
+ }
248
+ if (plan.remote.status === 'partial') {
249
+ return `- Used ${plan.remote.tokenName}, but one or more DistributionOS requests failed.`;
250
+ }
251
+ if (plan.remote.status === 'skipped') {
252
+ return '- Remote fetch skipped. Dry-run used local fallback instructions and did not call DistributionOS APIs.';
253
+ }
254
+ return '- No OAuth or env token found. Dry-run used local fallback instructions and did not call DistributionOS APIs.';
255
+ }
256
+
257
+ function usageSummary(usage) {
258
+ if (!usage) return [];
259
+ const plan = usage.plan
260
+ ? `- Plan: ${usage.plan.name}${usage.plan.priceLabel ? ` (${usage.plan.priceLabel})` : ''}`
261
+ : '- Plan: active account access';
262
+ const monthly = usage.monthly ?? {};
263
+ return [
264
+ plan,
265
+ `- Monthly caps: briefs ${meterText(monthly.brief_handoffs)}, images ${meterText(monthly.image_generations)}, research ${meterText(monthly.fresh_research_runs)}`,
266
+ ];
267
+ }
268
+
269
+ function meterText(value) {
270
+ if (!value) return 'unknown';
271
+ const limit = value.limit < 0 ? 'unlimited' : value.limit;
272
+ return `${value.used ?? 0}/${limit}`;
273
+ }
249
274
 
250
275
  function analyticsSummary(analytics) {
251
276
  if (analytics.status === 'enabled') {