diff --git a/README.md b/README.md index 4e8e35b..4dda840 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ polylane ... --non-interactive --quiet --output json | `POLYLANE_OUTPUT` | `text` or `json` (overrides TTY auto-detect) | | `POLYLANE_TIMEOUT` | Request timeout (seconds) | | `POLYLANE_VERBOSE` | Enable verbose HTTP logging | +| `POLYLANE_HINTS` | `0` / `false` / `off` suppresses next-step hints (guidance only — never data, status, errors, or prompts) | | `POLYLANE_TELEMETRY` | `0` / `false` / `off` disables anonymous usage telemetry | | `POLYLANE_TELEMETRY_ENDPOINT` | Override the telemetry endpoint (defaults to `/v1/telemetry/cli`) | | `DO_NOT_TRACK` | Universal `1` disables telemetry ([standard](https://consoledonottrack.com/)) | @@ -185,7 +186,8 @@ polylane ... --non-interactive --quiet --output json "workspace_id": "ws_xxxxx...", "api_key": "sk_xxxxx...", "output": "text", - "timeout": 300 + "timeout": 300, + "hints": true } ``` diff --git a/skill/SKILL.md b/skill/SKILL.md index 54aabbe..7b432d5 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -300,6 +300,7 @@ polylane issue list --quiet 2>/dev/null | `POLYLANE_OUTPUT` | `text` or `json` (overrides TTY auto-detect) | | `POLYLANE_TIMEOUT` | Request timeout (seconds) | | `POLYLANE_VERBOSE` | Verbose HTTP logs | +| `POLYLANE_HINTS` | `0` / `false` / `off` suppresses next-step hints (guidance only) | | `POLYLANE_TELEMETRY` | `0` / `false` / `off` disables anonymous telemetry | | `DO_NOT_TRACK` | `1` — universal opt-out | | `NO_COLOR` | Disable ANSI colours | diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 17add42..6fb268d 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -108,10 +108,8 @@ function workspaceStep(landing?: Landing): string[] { } } -export function nextSteps(expiresAt: string, landing?: Landing): string { +export function nextSteps(landing?: Landing): string { return [ - `Signed in. Session valid until ${expiresAt}.`, - ``, `Onboarding (in order):`, ``, ...workspaceStep(landing), @@ -205,13 +203,6 @@ function emitResult(config: Config, data: unknown): void { if (config.output === 'json') formatOutput(config, data); } -// The installer owns the post-sign-in journey (connects, mapping, topology -// link), so the CLI's own next-steps box would contradict it mid-flow. The -// env var is set per-invocation by the installer, never persisted. -function underInstaller(): boolean { - return Boolean(process.env.POLYLANE_ONBOARDING_RUN); -} - async function finishEmailSignIn(config: Config, email: string, session: VerifiedSession): Promise { if (!session.token) { emitResult(config, { landing: session.landing }); @@ -221,7 +212,7 @@ async function finishEmailSignIn(config: Config, email: string, session: Verifie writeSessionCredential(session.token, session.expiresAt, email); await persistDefaultWorkspace(config); emitResult(config, { token: session.token, landing: session.landing }); - if (!underInstaller()) note(nextSteps(session.expiresAt, session.landing), 'Next steps'); + if (config.hints) note(nextSteps(session.landing), 'Next steps'); outro(`Signed in as ${email}.`); } @@ -307,7 +298,7 @@ export async function emailSignup(config: Config, args: Record) writeSessionCredential(token, expiresAt, user.email ?? user.id); await persistDefaultWorkspace(config); emitResult(config, json.result); - if (!underInstaller()) note(nextSteps(expiresAt), 'Next steps'); + if (config.hints) note(nextSteps(), 'Next steps'); outro(`Signed in as ${user.email ?? user.id}.`); return; } diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 77466ae..ca2cdc7 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -13,7 +13,19 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requireArg } from '../helpers'; -const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'agent', 'output', 'timeout', 'telemetry']); +const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'agent', 'output', 'timeout', 'telemetry', 'hints']); + +function parseBooleanValue(key: string, value: string): boolean { + const truthy = ['1', 'true', 'yes', 'on', 'enabled']; + const falsy = ['0', 'false', 'no', 'off', 'disabled']; + if (truthy.includes(value)) return true; + if (falsy.includes(value)) return false; + throw new CLIError( + `Invalid ${key} value: "${value}"`, + ExitCode.USAGE, + 'Use true/false, yes/no, on/off, 1/0, or enabled/disabled' + ); +} export const configSetCommand: Command = { name: 'config set', @@ -69,17 +81,11 @@ export const configSetCommand: Command = { break; } case 'telemetry': { - const truthy = ['1', 'true', 'yes', 'on', 'enabled']; - const falsy = ['0', 'false', 'no', 'off', 'disabled']; - if (truthy.includes(value)) partial.telemetry = true; - else if (falsy.includes(value)) partial.telemetry = false; - else { - throw new CLIError( - `Invalid telemetry value: "${value}"`, - ExitCode.USAGE, - 'Use true/false, yes/no, on/off, 1/0, or enabled/disabled' - ); - } + partial.telemetry = parseBooleanValue(key, value); + break; + } + case 'hints': { + partial.hints = parseBooleanValue(key, value); break; } } diff --git a/src/commands/config/show.ts b/src/commands/config/show.ts index 20b9a12..12553a0 100644 --- a/src/commands/config/show.ts +++ b/src/commands/config/show.ts @@ -20,6 +20,7 @@ export const configShowCommand: Command = { agent: config.agent ?? null, output: config.output, timeout: config.timeout, + hints: config.hints, apiKey: config.apiKey ? maskToken(config.apiKey) : null, authMethod, configFile: CONFIG_FILE, diff --git a/src/config/loader.ts b/src/config/loader.ts index 01f3e2e..04089b8 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -39,7 +39,10 @@ function parseEnvNumber(v: string | undefined): number | undefined { function parseEnvBoolean(v: string | undefined): boolean | undefined { if (v === undefined) return undefined; - if (v === '' || v === '0' || v === 'false' || v === 'no') return false; + // 'off' and case-insensitivity match what the README has always documented + // for POLYLANE_TELEMETRY (and now POLYLANE_HINTS). + const norm = v.toLowerCase(); + if (norm === '' || norm === '0' || norm === 'false' || norm === 'no' || norm === 'off') return false; return true; } @@ -101,6 +104,17 @@ export function loadConfig(flags: GlobalFlags): Config { return true; })(); + // Hints are next-step guidance for humans. An orchestrator that owns the + // journey (e.g. the install script) sets POLYLANE_HINTS=0 so commands stay + // composable inside its flow. Same boolean model as telemetry: env → config + // file → default on. No CLI flag until a per-invocation need shows up. + const hints = ((): boolean => { + const fromEnv = parseEnvBoolean(env.POLYLANE_HINTS); + if (fromEnv !== undefined) return fromEnv; + if (file.hints !== undefined) return file.hints; + return true; + })(); + return { apiKey, domain, @@ -114,5 +128,6 @@ export function loadConfig(flags: GlobalFlags): Config { dryRun, nonInteractive, telemetry, + hints, }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 6a709fb..a7bc762 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,10 @@ export interface Config { dryRun: boolean; nonInteractive: boolean; telemetry: boolean; + /** Next-step guidance for humans (git's advice.* category). Suppressible + * by orchestrators that own the journey; never gates data, status lines, + * errors, prompts, or consent notices. */ + hints: boolean; } export interface RawConfig { @@ -26,6 +30,7 @@ export interface RawConfig { output?: OutputFormat; timeout?: number; telemetry?: boolean; + hints?: boolean; } // process.env.POLYLANE_API_DOMAIN is replaced at build time via esbuild define diff --git a/test/helpers/config.ts b/test/helpers/config.ts index e58516e..1883a5f 100644 --- a/test/helpers/config.ts +++ b/test/helpers/config.ts @@ -10,6 +10,7 @@ export function mockConfig(overrides: Partial = {}): Config { noColor: true, dryRun: false, nonInteractive: true, + hints: true, ...overrides, }; } diff --git a/test/loader.test.ts b/test/loader.test.ts index 3dab79c..5618b70 100644 --- a/test/loader.test.ts +++ b/test/loader.test.ts @@ -14,6 +14,7 @@ describe('loadConfig', () => { delete process.env.POLYLANE_OUTPUT; delete process.env.POLYLANE_VERBOSE; delete process.env.POLYLANE_AGENT; + delete process.env.POLYLANE_HINTS; }); afterEach(() => { @@ -59,4 +60,21 @@ describe('loadConfig', () => { const config = loadConfig({} as GlobalFlags); assert.equal(config.agent, undefined); }); + + it('hints default on', () => { + const config = loadConfig({} as GlobalFlags); + assert.equal(config.hints, true); + }); + + it('POLYLANE_HINTS=0 disables hints', () => { + process.env.POLYLANE_HINTS = '0'; + const config = loadConfig({} as GlobalFlags); + assert.equal(config.hints, false); + }); + + it('POLYLANE_HINTS=1 enables hints', () => { + process.env.POLYLANE_HINTS = '1'; + const config = loadConfig({} as GlobalFlags); + assert.equal(config.hints, true); + }); }); diff --git a/test/signup.test.ts b/test/signup.test.ts index fc4d94c..ed391e7 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -266,16 +266,11 @@ describe('auth signup existing-account re-auth', () => { assert.equal(config.workspace_id, WORKSPACE_ID); }); - it('prints next steps standalone but suppresses them under the installer', async () => { + it('prints next steps by default but not with hints disabled', async () => { await run({ output: 'text' }); assert.ok(output.includes('Onboarding (in order)')); - process.env.POLYLANE_ONBOARDING_RUN = 'run_test'; - try { - await run({ output: 'text' }); - } finally { - delete process.env.POLYLANE_ONBOARDING_RUN; - } + await run({ output: 'text', hints: false }); assert.ok(!output.includes('Onboarding (in order)')); assert.ok(output.includes('Signed in as dev@acme.com.')); }); @@ -386,44 +381,42 @@ describe('auth signup --code (email verification)', () => { }); describe('nextSteps', () => { - const expiresAt = '2026-08-10T00:00:00.000Z'; - it('names a created workspace and does not suggest creating one', () => { - const text = nextSteps(expiresAt, { kind: 'created', workspaceSlug: 'acme' }); + const text = nextSteps({ kind: 'created', workspaceSlug: 'acme' }); assert.ok(text.includes('Your first workspace ("acme") was created')); assert.ok(!text.includes('polylane workspace create')); }); it('names a joined workspace and does not suggest creating one', () => { - const text = nextSteps(expiresAt, { kind: 'joined', workspaceSlug: 'inviter' }); + const text = nextSteps({ kind: 'joined', workspaceSlug: 'inviter' }); assert.ok(text.includes('You joined the "inviter" workspace')); assert.ok(!text.includes('polylane workspace create')); }); it('points existing members at picking a default workspace', () => { - const text = nextSteps(expiresAt, { kind: 'existing' }); + const text = nextSteps({ kind: 'existing' }); assert.ok(text.includes('Set your default workspace')); assert.ok(!text.includes('polylane workspace create')); }); it('suggests creating a workspace when the landing kind is none', () => { - const text = nextSteps(expiresAt, { kind: 'none' }); + const text = nextSteps({ kind: 'none' }); assert.ok(text.includes('polylane workspace create')); }); it('suggests creating a workspace when no landing is present', () => { - const text = nextSteps(expiresAt); + const text = nextSteps(); assert.ok(text.includes('polylane workspace create')); }); it('does not crash when a created landing has no workspaceSlug', () => { - const text = nextSteps(expiresAt, { kind: 'created' }); + const text = nextSteps({ kind: 'created' }); assert.ok(text.includes('Your first workspace was created')); assert.ok(!text.includes('polylane workspace create')); }); it('suggests creating a workspace for an invite at capacity', () => { - const text = nextSteps(expiresAt, { + const text = nextSteps({ kind: 'invite_at_capacity', workspace: { id: 'ws_1', name: 'Inviter' }, });