@notis_ai/cli 0.2.0-beta.116.1 → 0.2.0-beta.118.1

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
@@ -4,7 +4,7 @@ Agent-first Notis CLI for apps and generic tool execution.
4
4
 
5
5
  ## Install
6
6
 
7
- Use the Notis CLI through NPX. Install and sign into Notis Desktop to keep the local CLI auth profile current; do not rely on an installed `notis` command. Related skills such as `notis-cli` and `notis-apps` are delivered through normal Notis skill sync for the signed-in user.
7
+ Use the Notis CLI through NPX; do not rely on an installed `notis` command. When Notis Desktop is signed in, its local credential remains the fastest path. On a server, container, or machine without Desktop, run `notis login` to authorize a scoped, revocable OAuth credential in the browser.
8
8
 
9
9
  For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>`.
10
10
 
@@ -12,12 +12,13 @@ For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOT
12
12
 
13
13
  ```bash
14
14
  npx --package @notis_ai/cli@latest -- notis --help
15
+ npx --package @notis_ai/cli@latest -- notis login
15
16
  npx --package @notis_ai/cli@latest -- notis doctor
16
17
  npx --package @notis_ai/cli@latest -- notis apps list
17
18
  npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"
18
19
  ```
19
20
 
20
- The desktop app owns sign-in and keeps the CLI profile in sync automatically.
21
+ Credential precedence is worktree runtime, `NOTIS_JWT`, a valid Desktop credential, then OAuth. `notis logout` revokes and removes OAuth without signing Desktop out. Use `notis login --paste-code` for the HTTPS copy-paste fallback on a remote machine.
21
22
 
22
23
  The CLI defaults to `json` output in agent or non-TTY contexts and `table` output in interactive terminals.
23
24
 
@@ -31,6 +32,44 @@ The CLI defaults to `json` output in agent or non-TTY contexts and `table` outpu
31
32
  - `--timeout-ms <n>` — HTTP timeout in milliseconds
32
33
  - `--idempotency-key <key>` — Override the generated idempotency key for mutating commands
33
34
 
35
+ ## Authentication
36
+
37
+ ### `npx --package @notis_ai/cli@latest -- notis login`
38
+
39
+ Authorize the Notis CLI in a browser with scoped OAuth access.
40
+
41
+ When to use: Use this on a machine where Notis Desktop is unavailable, signed out, or should not own CLI authentication.
42
+
43
+ Options:
44
+ - `--no-browser` — Print the authorization URL without opening a browser.
45
+ - `--print-url` — Print the authorization URL even when opening a browser.
46
+ - `--paste-code` — Use the copy-paste callback for SSH and headless machines.
47
+ - `--force` — Create an independent OAuth grant even when Desktop is signed in.
48
+ - `--timeout-seconds <n>` — How long to wait for authorization (default 300).
49
+ - `--scope <scope>` — OAuth permission to request (repeatable).
50
+ - `--code <code>` — Redeem the code shown in the browser after a non-interactive login.
51
+
52
+ Examples:
53
+ - `npx --package @notis_ai/cli@latest -- notis login`
54
+ - `npx --package @notis_ai/cli@latest -- notis login --no-browser --print-url`
55
+ - `npx --package @notis_ai/cli@latest -- notis login --paste-code`
56
+ - `npx --package @notis_ai/cli@latest -- notis login --code 4f3c2b1a`
57
+ - `npx --package @notis_ai/cli@latest -- notis login --force`
58
+
59
+ ### `npx --package @notis_ai/cli@latest -- notis logout`
60
+
61
+ Revoke and remove the scoped OAuth credential for the active CLI profile.
62
+
63
+ When to use: Use this to disconnect the command line without signing Notis Desktop out.
64
+
65
+ Options:
66
+ - `--all-profiles` — Remove OAuth credentials from every CLI profile.
67
+
68
+ Examples:
69
+ - `npx --package @notis_ai/cli@latest -- notis logout`
70
+ - `npx --package @notis_ai/cli@latest -- notis logout --all-profiles`
71
+
72
+
34
73
  ## Apps
35
74
 
36
75
  ### `npx --package @notis_ai/cli@latest -- notis apps list`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.116.1",
3
+ "version": "0.2.0-beta.118.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@ Important: `notis apps deploy` updates the linked installed app. It is not an ap
8
8
 
9
9
  ## Setup
10
10
 
11
- Install and sign into Notis Desktop to keep the CLI profile current. Run the CLI through NPX, for example `npx --package @notis_ai/cli@latest -- notis apps list`. Related skills such as `notis-cli` and `notis-apps` are delivered through normal Notis skill sync for the signed-in user.
11
+ Sign into Notis Desktop or run `npx --package @notis_ai/cli@latest -- notis login` to authorize the CLI. Run commands through NPX, for example `npx --package @notis_ai/cli@latest -- notis apps list`.
12
12
 
13
13
  For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>` and use `--api-base <server-url>` when targeting a non-default server.
14
14
 
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  name: notis-cli
3
3
  description: Use when agents should work through the Notis CLI, especially to develop Notis apps locally or to access Notis, Composio, or MCP tools they do not currently have loaded directly.
4
+ feature_flag: cli_access
5
+ mcp_resource: true
6
+ mcp_tool_patterns: []
4
7
  ---
5
8
 
6
9
  # Notis CLI Skill
@@ -4,7 +4,7 @@ Use the generic `notis tools` workflow through NPX for native Notis Database ope
4
4
 
5
5
  ## Setup
6
6
 
7
- Install and sign into Notis Desktop to keep the CLI profile current. Run the CLI through NPX, for example `npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"`.
7
+ Sign into Notis Desktop or run `npx --package @notis_ai/cli@latest -- notis login` to authorize the CLI. Run commands through NPX, for example `npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"`.
8
8
 
9
9
  For CI, hosted agents, or internal scripts, pass a non-persisted token with `NOTIS_JWT=<token>` and use `--api-base <server-url>` when targeting a non-default server.
10
10
 
@@ -55,6 +55,7 @@ import { getAvailablePort } from '../runtime/ports.js';
55
55
  import { getCliMode } from '../runtime/cli-mode.js';
56
56
  import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
57
57
  import { httpRequest } from '../runtime/transport.js';
58
+ import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
58
59
  import {
59
60
  localNotisToolSlug,
60
61
  nextIdempotencyKey,
@@ -1087,6 +1088,12 @@ async function appsVerifyHandler(ctx) {
1087
1088
 
1088
1089
  let linkedState = null;
1089
1090
  if (mode === 'live') {
1091
+ if (
1092
+ ctx.runtime.credentialKind === 'oauth'
1093
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1094
+ ) {
1095
+ throw usageError('Live verify mode requires a current OAuth grant. Run `notis login` and retry.');
1096
+ }
1090
1097
  if (!ctx.runtime.jwt) {
1091
1098
  throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
1092
1099
  }
@@ -1301,6 +1308,12 @@ async function appsScreenshotHandler(ctx) {
1301
1308
 
1302
1309
  let linkedState = null;
1303
1310
  if (mode === 'live') {
1311
+ if (
1312
+ ctx.runtime.credentialKind === 'oauth'
1313
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1314
+ ) {
1315
+ throw usageError('Live mode requires a current OAuth grant. Run `notis login` and retry.');
1316
+ }
1304
1317
  if (!ctx.runtime.jwt) {
1305
1318
  throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
1306
1319
  }
@@ -1539,6 +1552,12 @@ async function appsPullHandler(ctx) {
1539
1552
  toolName: GET_APP_TOOL,
1540
1553
  arguments_: { app_id: appId },
1541
1554
  });
1555
+ if (
1556
+ ctx.runtime.credentialKind === 'oauth'
1557
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1558
+ ) {
1559
+ throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1560
+ }
1542
1561
  const app = result.payload?.app || {};
1543
1562
  const defaultDir = app.slug || slugify(app.name) || appId;
1544
1563
  const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
@@ -0,0 +1,107 @@
1
+ import { loginWithOAuth, logoutOAuth } from '../runtime/oauth.js';
2
+
3
+ async function loginHandler(ctx) {
4
+ const result = await loginWithOAuth(ctx.runtime, ctx.options, ctx.output);
5
+ if (result.desktopFastPath) {
6
+ return ctx.output.emitSuccess({
7
+ command: 'login',
8
+ data: {
9
+ authenticated: true,
10
+ credential_source: result.credentialSource || 'desktop',
11
+ },
12
+ humanSummary: 'Notis Desktop already provides a valid CLI credential.',
13
+ });
14
+ }
15
+ if (result.agentAuthorization) {
16
+ return ctx.output.emitSuccess({
17
+ command: 'login',
18
+ data: result.agentAuthorization,
19
+ humanSummary: 'Open the authorization URL in a browser to continue.',
20
+ });
21
+ }
22
+ return ctx.output.emitSuccess({
23
+ command: 'login',
24
+ data: {
25
+ authenticated: true,
26
+ credential_source: 'oauth',
27
+ profile: ctx.runtime.profileName,
28
+ user_id: result.profile.oauth_user_id,
29
+ scopes: result.profile.oauth_scopes,
30
+ access_expires_at: result.profile.oauth_access_expires_at,
31
+ refresh_expires_at: result.profile.oauth_refresh_expires_at,
32
+ },
33
+ humanSummary: `Notis CLI is authorized for profile "${ctx.runtime.profileName}".`,
34
+ });
35
+ }
36
+
37
+ async function logoutHandler(ctx) {
38
+ const result = await logoutOAuth(ctx.runtime, {
39
+ allProfiles: Boolean(ctx.options.allProfiles),
40
+ });
41
+ return ctx.output.emitSuccess({
42
+ command: 'logout',
43
+ data: {
44
+ oauth_connected: false,
45
+ cleared_profiles: result.profiles,
46
+ },
47
+ humanSummary: ctx.options.allProfiles
48
+ ? 'OAuth credentials were removed from all CLI profiles.'
49
+ : `OAuth credentials were removed from profile "${ctx.runtime.profileName}".`,
50
+ });
51
+ }
52
+
53
+ export const authCommandSpecs = [
54
+ {
55
+ command_path: ['login'],
56
+ summary: 'Authorize the Notis CLI in a browser with scoped OAuth access.',
57
+ when_to_use:
58
+ 'Use this on a machine where Notis Desktop is unavailable, signed out, or should not own CLI authentication.',
59
+ args_schema: {
60
+ arguments: [],
61
+ options: [
62
+ { flags: '--no-browser', description: 'Print the authorization URL without opening a browser.' },
63
+ { flags: '--print-url', description: 'Print the authorization URL even when opening a browser.' },
64
+ { flags: '--paste-code', description: 'Use the copy-paste callback for SSH and headless machines.' },
65
+ { flags: '--force', description: 'Create an independent OAuth grant even when Desktop is signed in.' },
66
+ { flags: '--timeout-seconds <n>', description: 'How long to wait for authorization (default 300).' },
67
+ { flags: '--scope <scope>', description: 'OAuth permission to request (repeatable).', collect: true },
68
+ { flags: '--code <code>', description: 'Redeem the code shown in the browser after a non-interactive login.' },
69
+ ],
70
+ },
71
+ examples: [
72
+ 'notis login',
73
+ 'notis login --no-browser --print-url',
74
+ 'notis login --paste-code',
75
+ 'notis login --code 4f3c2b1a',
76
+ 'notis login --force',
77
+ ],
78
+ output_schema:
79
+ 'Returns credential_source, profile, user_id, scopes, and credential expiries; agent mode returns authorize_url and expires_in.',
80
+ mutates: true,
81
+ idempotent: true,
82
+ require_auth: false,
83
+ related_commands: ['notis logout', 'notis doctor', 'notis whoami'],
84
+ backend_call: { type: 'oauth', name: 'authorization_code+pkce' },
85
+ handler: loginHandler,
86
+ },
87
+ {
88
+ command_path: ['logout'],
89
+ summary: 'Revoke and remove the scoped OAuth credential for the active CLI profile.',
90
+ when_to_use:
91
+ 'Use this to disconnect the command line without signing Notis Desktop out.',
92
+ args_schema: {
93
+ arguments: [],
94
+ options: [
95
+ { flags: '--all-profiles', description: 'Remove OAuth credentials from every CLI profile.' },
96
+ ],
97
+ },
98
+ examples: ['notis logout', 'notis logout --all-profiles'],
99
+ output_schema: 'Returns oauth_connected=false and the profiles whose OAuth credentials were removed.',
100
+ mutates: true,
101
+ idempotent: true,
102
+ require_auth: false,
103
+ related_commands: ['notis login', 'notis doctor'],
104
+ backend_call: { type: 'oauth', name: 'revocation' },
105
+ handler: logoutHandler,
106
+ },
107
+ ];
@@ -4,6 +4,7 @@ import { metaCommandSpecs } from './meta.js';
4
4
  import { onboardingCommandSpecs } from './onboarding.js';
5
5
  import { diagnosticCommandSpecs } from './diagnostics.js';
6
6
  import { smokeCommandSpecs } from './smoke.js';
7
+ import { authCommandSpecs } from './auth.js';
7
8
 
8
9
  export const GROUP_SUMMARIES = {
9
10
  apps: 'Develop, deploy, and submit Notis Apps.',
@@ -13,6 +14,7 @@ export const GROUP_SUMMARIES = {
13
14
  };
14
15
 
15
16
  export const COMMAND_SPECS = [
17
+ ...authCommandSpecs,
16
18
  ...onboardingCommandSpecs,
17
19
  ...appsCommandSpecs,
18
20
  ...toolsCommandSpecs,
@@ -1,19 +1,52 @@
1
1
  import { COMPOSIO_SEARCH_TOOLS, healthCheck, probeAuth } from './helpers.js';
2
2
  import { findCommandSpec, formatDescribe } from '../runtime/help.js';
3
- import { createExpiredAuthError } from '../runtime/desktop-auth.js';
4
- import { isJwtExpired } from '../runtime/profiles.js';
3
+ import { createExpiredAuthError, getDesktopAuthRecovery } from '../runtime/desktop-auth.js';
4
+ import {
5
+ credentialIsExpired,
6
+ getJwtCanonicalUserId,
7
+ getProfile,
8
+ loadConfig,
9
+ } from '../runtime/profiles.js';
10
+ import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
11
+
12
+ export const DOCTOR_TOOL_ROUNDTRIP_TIMEOUT_MS = 90_000;
13
+
14
+ export function doctorToolRoundtripRuntime(runtime) {
15
+ return {
16
+ ...runtime,
17
+ timeoutMs: Math.max(runtime.timeoutMs || 0, DOCTOR_TOOL_ROUNDTRIP_TIMEOUT_MS),
18
+ };
19
+ }
5
20
 
6
21
  async function doctorHandler(ctx) {
7
22
  const checks = {
8
23
  config: 'ok',
9
24
  auth: 'missing',
25
+ identity: 'ok',
10
26
  health: 'unknown',
11
27
  tool_roundtrip: 'unknown',
12
28
  };
13
29
 
30
+ if (ctx.runtime.credentialKind === 'oauth') {
31
+ try {
32
+ await ensureFreshOAuthCredential(ctx.runtime);
33
+ } catch {
34
+ // Doctor still reports the remaining health and recovery checks when a
35
+ // refresh endpoint is unavailable or rejects the stored credential.
36
+ }
37
+ }
38
+ let profile = getProfile(loadConfig(ctx.runtime.worktreeRuntime), ctx.runtime.profileName);
14
39
  checks.auth = ctx.runtime.jwt
15
- ? (isJwtExpired(ctx.runtime.jwt) ? 'expired' : 'configured')
40
+ ? (credentialIsExpired(ctx.runtime, profile) ? 'expired' : 'configured')
16
41
  : 'missing';
42
+ const desktopUserId = getJwtCanonicalUserId(profile.jwt);
43
+ if (
44
+ desktopUserId
45
+ && profile.oauth_user_id
46
+ && desktopUserId !== profile.oauth_user_id
47
+ ) {
48
+ checks.identity = 'error';
49
+ }
17
50
 
18
51
  try {
19
52
  await healthCheck(ctx.runtime);
@@ -24,8 +57,15 @@ async function doctorHandler(ctx) {
24
57
 
25
58
  if (ctx.runtime.jwt) {
26
59
  try {
27
- const payload = await probeAuth(ctx.runtime);
60
+ // Tool discovery may have to query several connected MCP servers on a
61
+ // cold local backend. A diagnostic must not report a false failure just
62
+ // because that legitimate roundtrip exceeds the general 30s default.
63
+ const payload = await probeAuth(doctorToolRoundtripRuntime(ctx.runtime));
28
64
  checks.tool_roundtrip = Array.isArray(payload.toolkit_connection_statuses) ? 'ok' : 'error';
65
+ profile = getProfile(loadConfig(ctx.runtime.worktreeRuntime), ctx.runtime.profileName);
66
+ checks.auth = ctx.runtime.jwt
67
+ ? (credentialIsExpired(ctx.runtime, profile) ? 'expired' : 'configured')
68
+ : 'missing';
29
69
  } catch {
30
70
  checks.tool_roundtrip = 'error';
31
71
  }
@@ -33,7 +73,7 @@ async function doctorHandler(ctx) {
33
73
 
34
74
  const hints = [];
35
75
  if (checks.auth === 'missing') {
36
- hints.push({ command: 'Open the Notis desktop app and sign in', reason: 'Configure CLI credentials' });
76
+ hints.push(...getDesktopAuthRecovery(ctx.runtime, { mode: 'missing' }).hints);
37
77
  } else if (checks.auth === 'expired') {
38
78
  hints.push(...createExpiredAuthError(ctx.runtime).hints);
39
79
  }
@@ -43,12 +83,27 @@ async function doctorHandler(ctx) {
43
83
  if (checks.tool_roundtrip === 'error') {
44
84
  hints.push({ command: 'notis whoami', reason: 'Verify your account and permissions' });
45
85
  }
86
+ if (checks.identity === 'error') {
87
+ hints.push({
88
+ command: 'notis logout',
89
+ reason: 'Desktop and OAuth credentials identify different accounts; remove the independent OAuth grant or sign Desktop back into the same account',
90
+ });
91
+ }
46
92
 
47
93
  return ctx.output.emitSuccess({
48
94
  command: ctx.spec.command_path.join(' '),
49
95
  data: {
50
96
  profile: ctx.runtime.profileName,
51
97
  api_base: ctx.runtime.apiBase,
98
+ credential_source: ctx.runtime.credentialKind || null,
99
+ ...(ctx.runtime.credentialKind === 'oauth'
100
+ ? {
101
+ oauth_client_id: profile.oauth_client_id || null,
102
+ oauth_scopes: profile.oauth_scopes || [],
103
+ oauth_access_expires_at: profile.oauth_access_expires_at || null,
104
+ oauth_refresh_expires_at: profile.oauth_refresh_expires_at || null,
105
+ }
106
+ : {}),
52
107
  checks,
53
108
  },
54
109
  humanSummary: `Doctor checks completed for profile ${ctx.runtime.profileName}`,
@@ -72,16 +127,23 @@ function decodeJwtUserId(jwt) {
72
127
  }
73
128
  }
74
129
 
130
+ export function activeRuntimeUserId(runtime) {
131
+ return runtime.credentialKind === 'oauth'
132
+ ? runtime.oauthUserId
133
+ : decodeJwtUserId(runtime.jwt);
134
+ }
135
+
75
136
  async function whoamiHandler(ctx) {
76
137
  const payload = await probeAuth(ctx.runtime);
77
138
  const toolkits = payload.toolkit_connection_statuses || [];
78
- const userId = decodeJwtUserId(ctx.runtime.jwt);
139
+ const userId = activeRuntimeUserId(ctx.runtime);
79
140
 
80
141
  return ctx.output.emitSuccess({
81
142
  command: ctx.spec.command_path.join(' '),
82
143
  data: {
83
144
  profile: ctx.runtime.profileName,
84
145
  api_base: ctx.runtime.apiBase,
146
+ credential_source: ctx.runtime.credentialKind || null,
85
147
  user_id: userId,
86
148
  toolkit_count: toolkits.length,
87
149
  toolkits: toolkits.map((t) => t.toolkit),
@@ -4,7 +4,12 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { CliError, EXIT_CODES } from '../runtime/errors.js';
6
6
  import { getDesktopAuthRecovery, waitForDesktopAuth } from '../runtime/desktop-auth.js';
7
- import { getJwt, isJwtExpired, loadConfig } from '../runtime/profiles.js';
7
+ import {
8
+ credentialIsExpired,
9
+ getProfile,
10
+ loadConfig,
11
+ } from '../runtime/profiles.js';
12
+ import { ensureFreshOAuthCredential, loginWithOAuth } from '../runtime/oauth.js';
8
13
 
9
14
  const HERE = dirname(fileURLToPath(import.meta.url));
10
15
  const BUNDLED_BRIEF_PATH = join(HERE, '..', '..', 'skills', 'notis-onboarding', 'BRIEF.md');
@@ -85,7 +90,20 @@ async function requestSignupLink(apiBase, { email, useCases }) {
85
90
  async function startHandler(ctx) {
86
91
  const { runtime, options, output } = ctx;
87
92
  const apiBase = runtime.apiBase;
88
- const authenticated = Boolean(runtime.jwt) && !isJwtExpired(runtime.jwt);
93
+ if (runtime.credentialKind === 'oauth') {
94
+ try {
95
+ await ensureFreshOAuthCredential(runtime);
96
+ } catch {
97
+ // A failed refresh is equivalent to no usable session here. Interactive
98
+ // starts may still authorize again; brief-only and agent runs surface
99
+ // the normal authentication recovery below.
100
+ }
101
+ }
102
+ let authenticated = Boolean(runtime.jwt)
103
+ && !credentialIsExpired(
104
+ runtime,
105
+ getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
106
+ );
89
107
 
90
108
  // Safe to re-run: an already-authenticated machine skips straight to the brief.
91
109
  // Agents retry commands, and a second signup would invalidate the first email.
@@ -108,6 +126,54 @@ async function startHandler(ctx) {
108
126
  });
109
127
  }
110
128
 
129
+ if (
130
+ !options.email
131
+ && !runtime.agentMode
132
+ && !runtime.nonInteractive
133
+ && options.wait !== false
134
+ ) {
135
+ let authorization;
136
+ let authorizationError;
137
+ try {
138
+ authorization = await loginWithOAuth(runtime, { browser: true }, output);
139
+ } catch (error) {
140
+ // Keep the original OAuth failure while checking whether another process
141
+ // completed authentication before this attempt failed.
142
+ authorizationError = error;
143
+ authorization = null;
144
+ }
145
+ if (authorization?.agentAuthorization) {
146
+ return output.emitSuccess({
147
+ command: 'start',
148
+ data: { authenticated: false, ...authorization.agentAuthorization },
149
+ humanSummary: 'Open the authorization URL to finish signing in.',
150
+ renderHuman: () => `Authorize Notis CLI: ${authorization.agentAuthorization.authorize_url}`,
151
+ });
152
+ }
153
+ // A failed authorization leaves the stale credential that got us here in
154
+ // place, so re-apply the same expiry test used above rather than reporting
155
+ // a machine as signed in on the strength of a dead token.
156
+ authenticated = Boolean(runtime.jwt)
157
+ && !credentialIsExpired(runtime, getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName));
158
+ if (authenticated) {
159
+ const brief = await fetchBrief(apiBase, runtime.timeoutMs);
160
+ return output.emitSuccess({
161
+ command: 'start',
162
+ data: {
163
+ authenticated: true,
164
+ credential_source: runtime.credentialKind,
165
+ brief: brief.markdown,
166
+ brief_source: brief.source,
167
+ },
168
+ humanSummary: 'Notis CLI is authenticated. Follow the onboarding brief below.',
169
+ renderHuman: () => brief.markdown || 'Notis CLI is authenticated.',
170
+ });
171
+ }
172
+ if (authorizationError) {
173
+ throw authorizationError;
174
+ }
175
+ }
176
+
111
177
  if (!options.email) {
112
178
  // The one point in the flow that has to stop and talk to the human.
113
179
  throw new CliError({
@@ -143,8 +209,29 @@ async function startHandler(ctx) {
143
209
  const timeoutMs = Number(options.waitTimeoutMs) > 0 ? Number(options.waitTimeoutMs) : 300_000;
144
210
  const jwt = await waitForDesktopAuth({
145
211
  loadConfig,
146
- getJwt,
147
- isJwtExpired,
212
+ getJwt: (config, profileName) => {
213
+ const profile = getProfile(config, profileName);
214
+ if (
215
+ profile.jwt
216
+ && !credentialIsExpired(
217
+ { credentialKind: 'desktop', jwt: profile.jwt },
218
+ profile,
219
+ )
220
+ ) {
221
+ return profile.jwt;
222
+ }
223
+ if (
224
+ profile.oauth_access_token
225
+ && !credentialIsExpired(
226
+ { credentialKind: 'oauth', jwt: profile.oauth_access_token },
227
+ profile,
228
+ )
229
+ ) {
230
+ return profile.oauth_access_token;
231
+ }
232
+ return undefined;
233
+ },
234
+ isJwtExpired: () => false,
148
235
  profileName: runtime.profileName,
149
236
  timeoutMs,
150
237
  onTick: (remaining) => {
@@ -165,7 +252,8 @@ async function startHandler(ctx) {
165
252
  command: `Install Notis Desktop: ${signup.desktop_download_url || 'https://notis.ai/channels/desktop-app'}`,
166
253
  reason: 'The desktop app is what writes the CLI credential',
167
254
  },
168
- { command: 'notis start --brief-only', reason: 'Resume once the desktop app is signed in' },
255
+ { command: 'notis login', reason: 'Authorize this machine directly in a browser' },
256
+ { command: 'notis start --brief-only', reason: 'Resume once authentication is complete' },
169
257
  { command: 'notis doctor', reason: 'Re-check config, auth, and API reachability' },
170
258
  ],
171
259
  });
@@ -183,7 +271,7 @@ async function startHandler(ctx) {
183
271
  export const onboardingCommandSpecs = [
184
272
  {
185
273
  command_path: ['start'],
186
- summary: 'Create or access a Notis account and wait for the desktop app to sign the CLI in.',
274
+ summary: 'Create or access a Notis account and authorize the CLI.',
187
275
  when_to_use:
188
276
  'Run this first on a new machine, before anything that needs auth. Safe to re-run: an already-signed-in machine just reprints the onboarding brief.',
189
277
  args_schema: {
@@ -56,8 +56,8 @@ export function getDesktopAuthRecovery(runtime, { mode = 'expired' } = {}) {
56
56
  // Leads the list: a machine that was never signed in may not even have an
57
57
  // account yet, and `notis start` covers both cases.
58
58
  hints.unshift({
59
- command: 'notis start --email <address>',
60
- reason: 'Create or access a Notis account and sign this machine in',
59
+ command: 'notis login',
60
+ reason: 'Sign in or create an account in the browser and authorize this machine',
61
61
  });
62
62
  }
63
63
  hints.push({
@@ -100,6 +100,18 @@ export async function waitForDesktopAuth({
100
100
  }
101
101
 
102
102
  export function createExpiredAuthError(runtime) {
103
+ if (runtime.credentialKind === 'oauth') {
104
+ return new CliError({
105
+ code: 'auth_expired',
106
+ message: 'Notis CLI OAuth authentication has expired',
107
+ exitCode: EXIT_CODES.auth,
108
+ details: { credential_source: 'oauth' },
109
+ hints: [
110
+ { command: 'notis login', reason: 'Authorize a new scoped CLI credential' },
111
+ { command: 'notis doctor', reason: 'Inspect the active credential state' },
112
+ ],
113
+ });
114
+ }
103
115
  if (runtime.credentialSource === 'env') {
104
116
  return new CliError({
105
117
  code: 'auth_expired',
@@ -130,6 +142,14 @@ export function createExpiredAuthError(runtime) {
130
142
  }
131
143
 
132
144
  export function createInvalidAuthHints(runtime) {
145
+ if (runtime?.credentialKind === 'oauth') {
146
+ return [
147
+ {
148
+ command: 'notis login',
149
+ reason: 'Authorize a new scoped CLI credential',
150
+ },
151
+ ];
152
+ }
133
153
  if (runtime?.credentialSource === 'env') {
134
154
  return [
135
155
  {