@raidiant/notifai 9.2.0 → 9.2.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/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/commands-auth.js +3 -2
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-devices.js +5 -1
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-harness-context.d.ts +2 -2
- package/dist/commands-harness-context.js +1 -1
- package/dist/commands-hooks.d.ts +2 -0
- package/dist/commands-hooks.js +12 -6
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +18 -13
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +1 -1
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +1 -1
- package/dist/config.d.ts +5 -5
- package/dist/config.js +2 -2
- package/dist/invocation-context.d.ts +1 -1
- package/dist/invocation-context.js +2 -2
- package/dist/invocation-context.js.map +1 -1
- package/dist/program.js +14 -14
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +2 -2
- package/package.json +2 -2
- package/src/commands-auth.ts +3 -2
- package/src/commands-devices.test.ts +15 -0
- package/src/commands-devices.ts +5 -1
- package/src/commands-harness-context.ts +2 -2
- package/src/commands-hooks.ts +15 -9
- package/src/commands-init.ts +24 -14
- package/src/commands-send-support.ts +1 -1
- package/src/commands-send.ts +1 -1
- package/src/commands.test.ts +89 -26
- package/src/config.ts +5 -5
- package/src/invocation-context.test.ts +1 -1
- package/src/invocation-context.ts +2 -2
- package/src/onboarding-docs.test.ts +25 -0
- package/src/program.ts +14 -14
- package/src/send.ts +2 -2
- package/src/skill.test.ts +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidiant/notifai",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Send native device notifications from agents and local programs",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@clack/prompts": "^1.7.0",
|
|
24
|
-
"@raidiant/notifai-protocol": "5.0.
|
|
24
|
+
"@raidiant/notifai-protocol": "5.0.1",
|
|
25
25
|
"commander": "^14.0.0",
|
|
26
26
|
"picocolors": "^1.1.1",
|
|
27
27
|
"smol-toml": "^1.4.0"
|
package/src/commands-auth.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
reportError,
|
|
14
14
|
type CommandDeps,
|
|
15
15
|
} from './commands-core.js'
|
|
16
|
+
import { supportPageUrl } from './commands-devices.js'
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// login / logout / auth status
|
|
@@ -123,7 +124,7 @@ export async function loginCommand(
|
|
|
123
124
|
if (poll.status === 'no_active_plan') {
|
|
124
125
|
const next =
|
|
125
126
|
poll.next_action ??
|
|
126
|
-
`Open ${baseUrl
|
|
127
|
+
`Open ${supportPageUrl(baseUrl)} to request Alpha access, then retry.`
|
|
127
128
|
spinner?.error('Account has no Alpha access')
|
|
128
129
|
deps.io.err('This account has no active plan or temporary Alpha access.')
|
|
129
130
|
deps.io.err(`next: ${next}`)
|
|
@@ -189,7 +190,7 @@ export async function accessStatusCommand(
|
|
|
189
190
|
return access.status === 'active' ? EXIT.ok : EXIT.failed
|
|
190
191
|
}
|
|
191
192
|
if (access.status === 'no_active_plan') {
|
|
192
|
-
const supportUrl =
|
|
193
|
+
const supportUrl = supportPageUrl(authed.baseUrl)
|
|
193
194
|
deps.io.out('No active plan or temporary Alpha access for this account.')
|
|
194
195
|
deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
|
|
195
196
|
return EXIT.failed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { supportPageUrl } from './commands-devices.js'
|
|
3
|
+
|
|
4
|
+
describe('supportPageUrl', () => {
|
|
5
|
+
it('routes hosted control-plane origins to the dashboard support surface', () => {
|
|
6
|
+
expect(supportPageUrl('https://api.notifai.sh')).toBe('https://app.notifai.sh/support')
|
|
7
|
+
expect(supportPageUrl('https://notifai.fly.dev/')).toBe('https://app.notifai.sh/support')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps a self-hosted deployment on its own support route', () => {
|
|
11
|
+
expect(supportPageUrl('https://notifai.example.test/')).toBe(
|
|
12
|
+
'https://notifai.example.test/support',
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
})
|
package/src/commands-devices.ts
CHANGED
|
@@ -121,7 +121,11 @@ export async function capabilitiesCommand(
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
export function supportPageUrl(baseUrl: string): string {
|
|
124
|
-
|
|
124
|
+
const normalized = baseUrl.replace(/\/+$/, '')
|
|
125
|
+
if (normalized === 'https://api.notifai.sh' || normalized === 'https://notifai.fly.dev') {
|
|
126
|
+
return 'https://app.notifai.sh/support'
|
|
127
|
+
}
|
|
128
|
+
return `${normalized}/support`
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/** Same-account line for the device hop; prefers the real email when known. */
|
|
@@ -6,7 +6,7 @@ import { type Harness } from './install-hooks.js'
|
|
|
6
6
|
* harness. Configuration-directory variables are deliberately absent: they
|
|
7
7
|
* describe where a tool stores files, not which tool owns the current shell.
|
|
8
8
|
* OpenCode's generated plugin supplies the Notifai-owned marker because its
|
|
9
|
-
* plugin API exposes
|
|
9
|
+
* plugin API exposes Agent Session identity but the ordinary environment does not.
|
|
10
10
|
*/
|
|
11
11
|
export interface ActiveHarnessSession {
|
|
12
12
|
harness: Harness
|
|
@@ -67,7 +67,7 @@ interface ActiveHarnessResolution {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Which
|
|
70
|
+
* Which Agent Session owns this shell, when several claim to.
|
|
71
71
|
*
|
|
72
72
|
* Nesting is ordinary: an orchestrator running inside Claude Code starts a
|
|
73
73
|
* Codex session, and that Codex process inherits `CLAUDECODE` and
|
package/src/commands-hooks.ts
CHANGED
|
@@ -1124,12 +1124,12 @@ export function askCommand(
|
|
|
1124
1124
|
flags,
|
|
1125
1125
|
'session_identity_missing',
|
|
1126
1126
|
'exact_session',
|
|
1127
|
-
'Could not prove which live
|
|
1128
|
-
'run it from a supported harness with exact
|
|
1127
|
+
'Could not prove which live Agent Session owns this command.',
|
|
1128
|
+
'run it from a supported harness with exact Agent Session identity, or use a blocking `notifai send --reply` question',
|
|
1129
1129
|
)
|
|
1130
1130
|
}
|
|
1131
1131
|
if (!sessionId) {
|
|
1132
|
-
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active
|
|
1132
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active Agent Session is available.', 'use a blocking `notifai send --reply` question')
|
|
1133
1133
|
}
|
|
1134
1134
|
let routingConfig: CliConfig
|
|
1135
1135
|
try {
|
|
@@ -1592,6 +1592,8 @@ function parkClosedQuestion(
|
|
|
1592
1592
|
export interface HooksInstallFlags {
|
|
1593
1593
|
harness?: string
|
|
1594
1594
|
global?: boolean
|
|
1595
|
+
/** Init owns the final setup result and suppresses per-harness close narration. */
|
|
1596
|
+
narrate?: boolean
|
|
1595
1597
|
/** Test seam; production resolves the running CLI. */
|
|
1596
1598
|
execPath?: string
|
|
1597
1599
|
scriptPath?: string
|
|
@@ -1706,9 +1708,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1706
1708
|
return hooksInstallCommand(deps, { ...flags, global: true, harness })
|
|
1707
1709
|
}
|
|
1708
1710
|
const globalFile = globalInstallation?.file
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1711
|
+
if (flags.narrate !== false) {
|
|
1712
|
+
deps.io.out(
|
|
1713
|
+
`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1712
1716
|
return EXIT.ok
|
|
1713
1717
|
}
|
|
1714
1718
|
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
@@ -1731,6 +1735,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1731
1735
|
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1732
1736
|
platform: hookPlatform,
|
|
1733
1737
|
nodePath,
|
|
1738
|
+
...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
|
|
1734
1739
|
})
|
|
1735
1740
|
}
|
|
1736
1741
|
|
|
@@ -1755,7 +1760,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1755
1760
|
deps.io.err(String(err))
|
|
1756
1761
|
return EXIT.failed
|
|
1757
1762
|
}
|
|
1758
|
-
printHooksInstallClose(deps, harness, settingsTarget)
|
|
1763
|
+
if (flags.narrate !== false) printHooksInstallClose(deps, harness, settingsTarget)
|
|
1759
1764
|
return EXIT.ok
|
|
1760
1765
|
}
|
|
1761
1766
|
|
|
@@ -1806,7 +1811,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1806
1811
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
1807
1812
|
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
1808
1813
|
}
|
|
1809
|
-
printHooksInstallClose(deps, harness, installed.file)
|
|
1814
|
+
if (flags.narrate !== false) printHooksInstallClose(deps, harness, installed.file)
|
|
1810
1815
|
if (installed.foreignStopCount > 0) {
|
|
1811
1816
|
deps.io.out(
|
|
1812
1817
|
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
@@ -1853,6 +1858,7 @@ function installOpencodePlugin(
|
|
|
1853
1858
|
timeoutSeconds: number
|
|
1854
1859
|
platform?: NodeJS.Platform
|
|
1855
1860
|
nodePath?: string
|
|
1861
|
+
narrate?: boolean
|
|
1856
1862
|
},
|
|
1857
1863
|
): number {
|
|
1858
1864
|
try {
|
|
@@ -1874,7 +1880,7 @@ function installOpencodePlugin(
|
|
|
1874
1880
|
deps.io.err(String(err))
|
|
1875
1881
|
return EXIT.failed
|
|
1876
1882
|
}
|
|
1877
|
-
printHooksInstallClose(deps, 'opencode', file)
|
|
1883
|
+
if (options.narrate !== false) printHooksInstallClose(deps, 'opencode', file)
|
|
1878
1884
|
return EXIT.ok
|
|
1879
1885
|
}
|
|
1880
1886
|
|
package/src/commands-init.ts
CHANGED
|
@@ -13,7 +13,14 @@ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
|
13
13
|
import { personalProjectConfigPath, type CliConfig } from './config.js'
|
|
14
14
|
import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
|
|
15
15
|
import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
firstBlocker,
|
|
18
|
+
questionRoutingReady,
|
|
19
|
+
readinessJson,
|
|
20
|
+
type Readiness,
|
|
21
|
+
type ReadinessRefresh,
|
|
22
|
+
type ReadinessState,
|
|
23
|
+
} from './readiness.js'
|
|
17
24
|
import { buildDraft, formatReceipt } from './send.js'
|
|
18
25
|
import { loginCommand } from './commands-auth.js'
|
|
19
26
|
import {
|
|
@@ -116,6 +123,7 @@ async function closeGap(
|
|
|
116
123
|
flags: InitFlags,
|
|
117
124
|
): Promise<GapCloseResult> {
|
|
118
125
|
if (state.id === 'project') {
|
|
126
|
+
if (flags.setupScope === undefined) return 'failed'
|
|
119
127
|
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
120
128
|
const configPath =
|
|
121
129
|
flags.setupScope === 'global'
|
|
@@ -137,7 +145,7 @@ async function closeGap(
|
|
|
137
145
|
let ok = true
|
|
138
146
|
const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
|
|
139
147
|
for (const harness of harnesses) {
|
|
140
|
-
if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
|
|
148
|
+
if (hooksInstallCommand(deps, { harness, ...hookFlags, narrate: false }) !== EXIT.ok) ok = false
|
|
141
149
|
}
|
|
142
150
|
return ok ? 'closed' : 'failed'
|
|
143
151
|
}
|
|
@@ -619,9 +627,9 @@ async function resolveSetupScope(
|
|
|
619
627
|
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
620
628
|
return 'usage'
|
|
621
629
|
}
|
|
622
|
-
if (flags.skills === true) {
|
|
630
|
+
if (flags.skills === true || flags.hooks === true) {
|
|
623
631
|
deps.io.err(
|
|
624
|
-
'Unattended
|
|
632
|
+
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
625
633
|
)
|
|
626
634
|
return 'usage'
|
|
627
635
|
}
|
|
@@ -683,6 +691,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
683
691
|
|
|
684
692
|
if (state.status === 'optional-gap') {
|
|
685
693
|
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
694
|
+
if (resolved.setupScope === undefined && state.id === 'project') {
|
|
695
|
+
stop = true
|
|
696
|
+
break
|
|
697
|
+
}
|
|
686
698
|
attempted.add(state.id)
|
|
687
699
|
const result = await closeGap(workingDeps, state, resolved)
|
|
688
700
|
if (result === 'failed') failed = true
|
|
@@ -701,6 +713,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
701
713
|
}
|
|
702
714
|
|
|
703
715
|
if (remedy.by === 'cli') {
|
|
716
|
+
if (resolved.setupScope === undefined && state.id === 'project') {
|
|
717
|
+
stop = true
|
|
718
|
+
break
|
|
719
|
+
}
|
|
704
720
|
attempted.add(state.id)
|
|
705
721
|
const result = await closeGap(workingDeps, state, resolved)
|
|
706
722
|
if (result === 'failed') failed = true
|
|
@@ -783,12 +799,6 @@ function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly Readiness
|
|
|
783
799
|
}
|
|
784
800
|
}
|
|
785
801
|
|
|
786
|
-
function questionsWillRoute(readiness: Readiness): boolean {
|
|
787
|
-
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
788
|
-
const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
|
|
789
|
-
return hooks?.status === 'ready' && settings?.status !== 'gap'
|
|
790
|
-
}
|
|
791
|
-
|
|
792
802
|
async function printInitClose(
|
|
793
803
|
deps: CommandDeps,
|
|
794
804
|
readiness: Readiness,
|
|
@@ -796,7 +806,7 @@ async function printInitClose(
|
|
|
796
806
|
): Promise<void> {
|
|
797
807
|
const blocker = firstBlocker(readiness)
|
|
798
808
|
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
799
|
-
const questions =
|
|
809
|
+
const questions = questionRoutingReady(readiness)
|
|
800
810
|
const leftovers = leftoverOptionals(readiness, flags).filter(
|
|
801
811
|
(state) => state.id !== 'contract',
|
|
802
812
|
)
|
|
@@ -813,14 +823,14 @@ async function printInitClose(
|
|
|
813
823
|
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
814
824
|
questions
|
|
815
825
|
? 'Questions will reach your devices.'
|
|
816
|
-
: 'Questions
|
|
826
|
+
: 'Questions are terminal-only until Question Routing is ready.',
|
|
817
827
|
]
|
|
818
828
|
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
819
829
|
printOptionalLeftovers(deps, leftovers)
|
|
820
830
|
deps.io.out(
|
|
821
831
|
questions
|
|
822
832
|
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
823
|
-
: 'All set. Agents in this project can notify you. Questions
|
|
833
|
+
: 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
824
834
|
)
|
|
825
835
|
await deps.io.outro?.('All set ✨')
|
|
826
836
|
return
|
|
@@ -840,7 +850,7 @@ async function printInitClose(
|
|
|
840
850
|
deps.io.out(
|
|
841
851
|
questions
|
|
842
852
|
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
843
|
-
: 'All set. Agents in this project can notify you. Questions
|
|
853
|
+
: 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
844
854
|
)
|
|
845
855
|
return
|
|
846
856
|
}
|
|
@@ -17,7 +17,7 @@ function managedSessionTitle(
|
|
|
17
17
|
): string | undefined {
|
|
18
18
|
if (active?.sessionLabel !== undefined) return active.sessionLabel
|
|
19
19
|
// The Orca worktree title names the workspace, not one harness: every
|
|
20
|
-
//
|
|
20
|
+
// Agent Session that Orca starts inside that worktree inherits the same
|
|
21
21
|
// environment selector, so any active session with an identity to label
|
|
22
22
|
// may use it. An explicit label still wins without paying for the lookup.
|
|
23
23
|
if (
|
package/src/commands-send.ts
CHANGED
|
@@ -392,7 +392,7 @@ function emitSendWarnings(
|
|
|
392
392
|
}
|
|
393
393
|
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
394
394
|
deps.io.err(
|
|
395
|
-
'Heads up: keep the title to the specific substance. Put notification type in --kind;
|
|
395
|
+
'Heads up: keep the title to the specific substance. Put notification type in --kind; Project identity is inferred separately.',
|
|
396
396
|
)
|
|
397
397
|
}
|
|
398
398
|
if (
|
package/src/commands.test.ts
CHANGED
|
@@ -4202,7 +4202,7 @@ describe('init', () => {
|
|
|
4202
4202
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4203
4203
|
}
|
|
4204
4204
|
|
|
4205
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4205
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4206
4206
|
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
4207
4207
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
4208
4208
|
// Safe by default: without an explicit --skills opt-in, init only writes
|
|
@@ -4211,7 +4211,7 @@ describe('init', () => {
|
|
|
4211
4211
|
expect(io.outLines.join('\n')).not.toContain('All set.')
|
|
4212
4212
|
|
|
4213
4213
|
io.outLines = []
|
|
4214
|
-
expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
|
|
4214
|
+
expect(await initCommand(deps, { setupScope: 'project', skills: false })).toBe(EXIT.failed)
|
|
4215
4215
|
// Idempotent: the second run re-derives the same slug and does not reprint
|
|
4216
4216
|
// doctor's ready-state dump.
|
|
4217
4217
|
expect(io.outLines.join('\n')).toMatch(/^Next:/m)
|
|
@@ -4253,7 +4253,13 @@ describe('init', () => {
|
|
|
4253
4253
|
env: isolatedEnv(cwd),
|
|
4254
4254
|
}
|
|
4255
4255
|
|
|
4256
|
-
expect(
|
|
4256
|
+
expect(
|
|
4257
|
+
await initCommand(deps, {
|
|
4258
|
+
hooks: false,
|
|
4259
|
+
setupScope: 'project',
|
|
4260
|
+
skills: false,
|
|
4261
|
+
}),
|
|
4262
|
+
).toBe(EXIT.failed)
|
|
4257
4263
|
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
4258
4264
|
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
4259
4265
|
expect(io.outros).toEqual([])
|
|
@@ -4264,10 +4270,16 @@ describe('init', () => {
|
|
|
4264
4270
|
io.errLines = []
|
|
4265
4271
|
io.outros = []
|
|
4266
4272
|
|
|
4267
|
-
expect(
|
|
4273
|
+
expect(
|
|
4274
|
+
await initCommand(deps, {
|
|
4275
|
+
hooks: false,
|
|
4276
|
+
setupScope: 'project',
|
|
4277
|
+
skills: false,
|
|
4278
|
+
}),
|
|
4279
|
+
).toBe(EXIT.ok)
|
|
4268
4280
|
expect(submissions).toBe(1)
|
|
4269
4281
|
expect(io.outLines).toContain(
|
|
4270
|
-
'All set. Agents in this project can notify you. Questions
|
|
4282
|
+
'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
4271
4283
|
)
|
|
4272
4284
|
expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
|
|
4273
4285
|
expect(io.errLines).toEqual([])
|
|
@@ -4302,22 +4314,31 @@ describe('init', () => {
|
|
|
4302
4314
|
const io = new CapturedIo()
|
|
4303
4315
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4304
4316
|
|
|
4305
|
-
expect(
|
|
4317
|
+
expect(
|
|
4318
|
+
await initCommand(deps, {
|
|
4319
|
+
projectId: 'Custom Name',
|
|
4320
|
+
setupScope: 'project',
|
|
4321
|
+
skills: false,
|
|
4322
|
+
}),
|
|
4323
|
+
).toBe(EXIT.failed)
|
|
4306
4324
|
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain(
|
|
4307
4325
|
'project = "custom-name"',
|
|
4308
4326
|
)
|
|
4309
4327
|
})
|
|
4310
4328
|
|
|
4311
|
-
it('
|
|
4312
|
-
// An agent's init must not reach for npx or a prompt.
|
|
4329
|
+
it('does not let an unattended readiness pass silently choose project scope', async () => {
|
|
4313
4330
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
|
|
4314
4331
|
const io = new CapturedIo()
|
|
4315
4332
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4316
4333
|
|
|
4317
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4334
|
+
expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
|
|
4335
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
|
|
4336
|
+
const result = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4337
|
+
states: Array<{ id: string; status: string }>
|
|
4338
|
+
}
|
|
4339
|
+
expect(result.states.find((state) => state.id === 'project')).toMatchObject({
|
|
4340
|
+
status: 'optional-gap',
|
|
4341
|
+
})
|
|
4321
4342
|
expect(io.errLines).toEqual([])
|
|
4322
4343
|
})
|
|
4323
4344
|
|
|
@@ -4765,7 +4786,7 @@ describe('init', () => {
|
|
|
4765
4786
|
list: async () => ({ skills: [] }),
|
|
4766
4787
|
}
|
|
4767
4788
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
4768
|
-
await initCommand(deps, { hooks: true, skills: false })
|
|
4789
|
+
await initCommand(deps, { hooks: true, setupScope: 'project', skills: false })
|
|
4769
4790
|
const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
|
|
4770
4791
|
(installation) => installation.harness,
|
|
4771
4792
|
)
|
|
@@ -4773,8 +4794,9 @@ describe('init', () => {
|
|
|
4773
4794
|
expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
|
|
4774
4795
|
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
4775
4796
|
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
|
|
4776
|
-
expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
|
|
4777
|
-
expect(io.outLines.join('\n')).toContain('Installed codex hooks')
|
|
4797
|
+
expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
|
|
4798
|
+
expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
|
|
4799
|
+
expect(io.outLines.join('\n')).toContain('Next: Codex hook trust')
|
|
4778
4800
|
})
|
|
4779
4801
|
|
|
4780
4802
|
it('lets a human keep a subset of the detected harnesses', async () => {
|
|
@@ -4825,7 +4847,7 @@ describe('init', () => {
|
|
|
4825
4847
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4826
4848
|
}
|
|
4827
4849
|
|
|
4828
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4850
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4829
4851
|
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
4830
4852
|
})
|
|
4831
4853
|
|
|
@@ -4854,7 +4876,7 @@ describe('init', () => {
|
|
|
4854
4876
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4855
4877
|
}
|
|
4856
4878
|
|
|
4857
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4879
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4858
4880
|
expect(asked.some((q) => q.includes('Sign in'))).toBe(false)
|
|
4859
4881
|
const out = io.outLines.join('\n')
|
|
4860
4882
|
expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
@@ -4949,7 +4971,7 @@ describe('init', () => {
|
|
|
4949
4971
|
} satisfies NativeSkills,
|
|
4950
4972
|
}
|
|
4951
4973
|
|
|
4952
|
-
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
4974
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.ok)
|
|
4953
4975
|
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/hooks/)]))
|
|
4954
4976
|
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/skill/)]))
|
|
4955
4977
|
const out = io.outLines.join('\n')
|
|
@@ -5026,7 +5048,13 @@ describe('init', () => {
|
|
|
5026
5048
|
},
|
|
5027
5049
|
}
|
|
5028
5050
|
|
|
5029
|
-
expect(
|
|
5051
|
+
expect(
|
|
5052
|
+
await initCommand(deps, {
|
|
5053
|
+
hooks: false,
|
|
5054
|
+
setupScope: 'project',
|
|
5055
|
+
skills: false,
|
|
5056
|
+
}),
|
|
5057
|
+
).toBe(EXIT.ok)
|
|
5030
5058
|
expect(io.prompts).toEqual([
|
|
5031
5059
|
'Open install instructions in your browser?',
|
|
5032
5060
|
'Wait here while you finish that on your device?',
|
|
@@ -5049,7 +5077,13 @@ describe('init', () => {
|
|
|
5049
5077
|
io.outLines = []
|
|
5050
5078
|
io.prompts = []
|
|
5051
5079
|
io.openedUrls = []
|
|
5052
|
-
expect(
|
|
5080
|
+
expect(
|
|
5081
|
+
await initCommand(deps, {
|
|
5082
|
+
hooks: false,
|
|
5083
|
+
setupScope: 'project',
|
|
5084
|
+
skills: false,
|
|
5085
|
+
}),
|
|
5086
|
+
).toBe(EXIT.ok)
|
|
5053
5087
|
expect(submitCalls).toBe(1)
|
|
5054
5088
|
expect(io.prompts).toEqual([])
|
|
5055
5089
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
@@ -5094,7 +5128,13 @@ describe('init', () => {
|
|
|
5094
5128
|
},
|
|
5095
5129
|
}
|
|
5096
5130
|
|
|
5097
|
-
expect(
|
|
5131
|
+
expect(
|
|
5132
|
+
await initCommand(deps, {
|
|
5133
|
+
hooks: false,
|
|
5134
|
+
setupScope: 'project',
|
|
5135
|
+
skills: false,
|
|
5136
|
+
}),
|
|
5137
|
+
).toBe(EXIT.ok)
|
|
5098
5138
|
expect(io.errLines.join('\n')).toContain('wait timer expired')
|
|
5099
5139
|
expect(io.errLines.join('\n')).toMatch(/setup is not finished, only this wait/)
|
|
5100
5140
|
expect(io.prompts.some((q) => q.includes('Keep waiting'))).toBe(true)
|
|
@@ -5130,7 +5170,13 @@ describe('init', () => {
|
|
|
5130
5170
|
env: isolatedEnv(cwd),
|
|
5131
5171
|
}
|
|
5132
5172
|
|
|
5133
|
-
expect(
|
|
5173
|
+
expect(
|
|
5174
|
+
await initCommand(deps, {
|
|
5175
|
+
hooks: false,
|
|
5176
|
+
setupScope: 'project',
|
|
5177
|
+
skills: false,
|
|
5178
|
+
}),
|
|
5179
|
+
).toBe(EXIT.failed)
|
|
5134
5180
|
const out = io.outLines.join('\n')
|
|
5135
5181
|
expect(out).toContain('Next: Your devices')
|
|
5136
5182
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
@@ -5178,7 +5224,13 @@ describe('init', () => {
|
|
|
5178
5224
|
},
|
|
5179
5225
|
}
|
|
5180
5226
|
|
|
5181
|
-
expect(
|
|
5227
|
+
expect(
|
|
5228
|
+
await initCommand(deps, {
|
|
5229
|
+
hooks: false,
|
|
5230
|
+
setupScope: 'project',
|
|
5231
|
+
skills: false,
|
|
5232
|
+
}),
|
|
5233
|
+
).toBe(EXIT.failed)
|
|
5182
5234
|
expect(submitCalls).toBe(1)
|
|
5183
5235
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
5184
5236
|
expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
|
|
@@ -5266,7 +5318,13 @@ describe('init', () => {
|
|
|
5266
5318
|
env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
|
|
5267
5319
|
}
|
|
5268
5320
|
|
|
5269
|
-
await expect(
|
|
5321
|
+
await expect(
|
|
5322
|
+
initCommand(deps, {
|
|
5323
|
+
hooks: false,
|
|
5324
|
+
setupScope: 'project',
|
|
5325
|
+
skills: false,
|
|
5326
|
+
}),
|
|
5327
|
+
).resolves.toBe(EXIT.failed)
|
|
5270
5328
|
expect(submitCalls).toBe(1)
|
|
5271
5329
|
expect(io.errLines.join('\n')).toContain('Could not save setup proof req_unwritable')
|
|
5272
5330
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
@@ -5418,7 +5476,12 @@ describe('init', () => {
|
|
|
5418
5476
|
env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
|
|
5419
5477
|
}
|
|
5420
5478
|
expect(
|
|
5421
|
-
await initCommand(deps, {
|
|
5479
|
+
await initCommand(deps, {
|
|
5480
|
+
projectId: 'shared-project',
|
|
5481
|
+
hooks: false,
|
|
5482
|
+
setupScope: 'project',
|
|
5483
|
+
skills: false,
|
|
5484
|
+
}),
|
|
5422
5485
|
).toBe(EXIT.ok)
|
|
5423
5486
|
}
|
|
5424
5487
|
|
|
@@ -5500,7 +5563,7 @@ describe('readiness assessment cost', () => {
|
|
|
5500
5563
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
5501
5564
|
}
|
|
5502
5565
|
|
|
5503
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
5566
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
5504
5567
|
expect(healthCalls).toBe(1)
|
|
5505
5568
|
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project =')
|
|
5506
5569
|
})
|
package/src/config.ts
CHANGED
|
@@ -21,8 +21,8 @@ import { normalizeOrigin } from './url-policy.js'
|
|
|
21
21
|
* preference that evaporates mid-session is worse than none.
|
|
22
22
|
*
|
|
23
23
|
* `project-local` is a personal file under the user's config directory, keyed
|
|
24
|
-
* by the
|
|
25
|
-
* `.notifai/config.toml` stays in the tree for committed
|
|
24
|
+
* by the Project root so a checkout does not have to gitignore anything. Shared
|
|
25
|
+
* `.notifai/config.toml` stays in the tree for committed Project identity.
|
|
26
26
|
*
|
|
27
27
|
* `config show --explain` surfaces the winning layer per key.
|
|
28
28
|
*/
|
|
@@ -245,9 +245,9 @@ export function sanitizeSessionId(sessionId: string): string {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* Pointer from a
|
|
248
|
+
* Pointer from a Project directory to the Agent Session currently working in
|
|
249
249
|
* it, written by the UserPromptSubmit hook. Without it `notifai ask` has no way
|
|
250
|
-
* to learn its own
|
|
250
|
+
* to learn its own Agent Session id: an ordinary agent shell command receives no hook
|
|
251
251
|
* payload, and no harness exports one into the environment.
|
|
252
252
|
*/
|
|
253
253
|
export function projectSessionPointerPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -420,7 +420,7 @@ export function loadConfig(options: {
|
|
|
420
420
|
cwd?: string
|
|
421
421
|
env?: NodeJS.ProcessEnv
|
|
422
422
|
flags?: FlagOverrides
|
|
423
|
-
/**
|
|
423
|
+
/** Agent Session id; enables the Agent Session override layer when present. */
|
|
424
424
|
sessionId?: string | undefined
|
|
425
425
|
}): CliConfig {
|
|
426
426
|
const env = options.env ?? process.env
|
|
@@ -346,7 +346,7 @@ describe('buildSourceContext', () => {
|
|
|
346
346
|
}),
|
|
347
347
|
).toEqual({
|
|
348
348
|
ok: false,
|
|
349
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact
|
|
349
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
350
350
|
})
|
|
351
351
|
})
|
|
352
352
|
})
|
|
@@ -150,7 +150,7 @@ export type SourceContextBuild =
|
|
|
150
150
|
| { ok: true; source?: SourceContextT }
|
|
151
151
|
| { ok: false; error: string }
|
|
152
152
|
|
|
153
|
-
/** Resolve per-field
|
|
153
|
+
/** Resolve per-field Source Context precedence without fabricating Agent Session identity. */
|
|
154
154
|
export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
|
|
155
155
|
const sessionId =
|
|
156
156
|
input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
|
|
@@ -158,7 +158,7 @@ export function buildSourceContext(input: SourceContextInput): SourceContextBuil
|
|
|
158
158
|
if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
|
|
159
159
|
return {
|
|
160
160
|
ok: false,
|
|
161
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact
|
|
161
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
if (sessionId === '') {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const publicReadme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
5
|
+
const cliReadme = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
6
|
+
|
|
7
|
+
describe('public onboarding docs', () => {
|
|
8
|
+
it('installs the CLI before the first quick-start invocation', () => {
|
|
9
|
+
const using = publicReadme.slice(publicReadme.indexOf('## Using it'))
|
|
10
|
+
const install = using.indexOf('npm install -g @raidiant/notifai')
|
|
11
|
+
const firstInvocation = using.search(/^notifai(?:\s|$)/m)
|
|
12
|
+
|
|
13
|
+
expect(install).toBeGreaterThan(0)
|
|
14
|
+
expect(firstInvocation).toBeGreaterThan(install)
|
|
15
|
+
expect(using.slice(install, firstInvocation)).toContain('@raidiant/notifai')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('states the active Android external-test boundary without promising a public release', () => {
|
|
19
|
+
const harnesses = cliReadme.slice(cliReadme.indexOf('## Agent harnesses'))
|
|
20
|
+
|
|
21
|
+
expect(harnesses).toMatch(/Android Companion App is active[\s\S]*external testing/i)
|
|
22
|
+
expect(harnesses).toMatch(/Firebase App Distribution/i)
|
|
23
|
+
expect(harnesses).toMatch(/not a\s+public release or a Google Play promise/i)
|
|
24
|
+
})
|
|
25
|
+
})
|