@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 +433 -133
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +16 -6
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +716 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/layout.tsx +5 -2
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +4137 -0
- package/template/package.json +5 -5
- package/template/packages/{notis-sdk → sdk}/package.json +13 -3
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +213 -0
- package/template/packages/sdk/src/tailwind.ts +56 -0
- package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
- package/template/tailwind.config.ts +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
2
|
+
import { channelFromProfile, cliCommandForChannel } from './channel.js';
|
|
3
|
+
|
|
4
|
+
// Recovery is only useful when the printed command is the one that will run:
|
|
5
|
+
// telling a beta profile to reinstall `@latest` sends it back to the build it
|
|
6
|
+
// just failed on.
|
|
7
|
+
function cliNpx(runtime = {}) {
|
|
8
|
+
return cliCommandForChannel(
|
|
9
|
+
runtime.channel || channelFromProfile({ api_base: runtime.apiBase }),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function quoteShellArgument(value) {
|
|
14
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function profileSuffix(profileName) {
|
|
18
|
+
return profileName && profileName !== 'default'
|
|
19
|
+
? ` --profile ${quoteShellArgument(profileName)}`
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How to get an unusable profile back to a working credential.
|
|
25
|
+
*
|
|
26
|
+
* `mode` distinguishes the two cases a caller has to act on differently:
|
|
27
|
+
* "expired" means the profile holds a grant the browser can renew, while
|
|
28
|
+
* "missing" means this profile has never been authorized at all.
|
|
29
|
+
*/
|
|
30
|
+
export function getAuthRecovery(runtime = {}, { mode = 'expired' } = {}) {
|
|
31
|
+
const CLI_NPX = cliNpx(runtime);
|
|
32
|
+
const suffix = profileSuffix(runtime.profileName);
|
|
33
|
+
const hints = [
|
|
34
|
+
{
|
|
35
|
+
command: `${CLI_NPX} login${suffix}`,
|
|
36
|
+
reason: mode === 'missing'
|
|
37
|
+
? 'Sign in or create an account in the browser and authorize this machine'
|
|
38
|
+
: 'Authorize a fresh scoped CLI credential for this profile',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
command: `${CLI_NPX} profile list`,
|
|
42
|
+
reason: 'Check whether another profile on this machine is already signed in',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
command: `${CLI_NPX} doctor${suffix}`,
|
|
46
|
+
reason: 'Retry the auth and API checks once authorization completes',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
return { hints };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createExpiredAuthError(runtime) {
|
|
53
|
+
const CLI_NPX = cliNpx(runtime || {});
|
|
54
|
+
if (runtime?.credentialKind === 'worktree') {
|
|
55
|
+
return new CliError({
|
|
56
|
+
code: 'auth_expired',
|
|
57
|
+
message: `The ./dev.sh credential for profile "${runtime.profileName}" has expired`,
|
|
58
|
+
exitCode: EXIT_CODES.auth,
|
|
59
|
+
details: { credential_source: 'worktree' },
|
|
60
|
+
hints: [
|
|
61
|
+
{ message: 'Restart ./dev.sh in this worktree to mint a fresh dev credential.' },
|
|
62
|
+
{
|
|
63
|
+
command: `${CLI_NPX} profile list`,
|
|
64
|
+
reason: 'Switch to a live account profile instead of the dev one',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (runtime?.credentialSource === 'env') {
|
|
70
|
+
return new CliError({
|
|
71
|
+
code: 'auth_expired',
|
|
72
|
+
message: 'NOTIS_JWT is expired',
|
|
73
|
+
exitCode: EXIT_CODES.auth,
|
|
74
|
+
details: { credential_source: 'env' },
|
|
75
|
+
hints: [
|
|
76
|
+
{
|
|
77
|
+
command: 'Set NOTIS_JWT to a fresh token',
|
|
78
|
+
reason: 'The explicit environment credential overrides every stored profile',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new CliError({
|
|
85
|
+
code: 'auth_expired',
|
|
86
|
+
message: `Notis CLI authorization for profile "${runtime?.profileName || 'default'}" has expired`,
|
|
87
|
+
exitCode: EXIT_CODES.auth,
|
|
88
|
+
details: { credential_source: 'oauth' },
|
|
89
|
+
hints: getAuthRecovery(runtime).hints,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createInvalidAuthHints(runtime) {
|
|
94
|
+
if (runtime?.credentialKind === 'worktree') {
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
message: 'Restart ./dev.sh in this worktree to mint a fresh dev credential.',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
if (runtime?.credentialSource === 'env') {
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
command: 'Set NOTIS_JWT to a fresh token',
|
|
105
|
+
reason: 'The explicit environment credential was rejected',
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return getAuthRecovery(runtime || {}).hints;
|
|
110
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface BaseSkillReconcileResult {
|
|
2
|
+
installed: number;
|
|
3
|
+
linked: number;
|
|
4
|
+
unchanged: number;
|
|
5
|
+
backups: string[];
|
|
6
|
+
skills: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const BASE_SKILL_NAMES: readonly string[];
|
|
10
|
+
|
|
11
|
+
export function resolveBundledBaseSkillsRoot(options?: {
|
|
12
|
+
resourcesPath?: string;
|
|
13
|
+
}): string;
|
|
14
|
+
|
|
15
|
+
export function reconcileBaseSkills(options?: {
|
|
16
|
+
home?: string;
|
|
17
|
+
userId?: string | null;
|
|
18
|
+
sourceRoot?: string;
|
|
19
|
+
now?: number;
|
|
20
|
+
}): BaseSkillReconcileResult;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readlinkSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
export const BASE_SKILL_NAMES = Object.freeze([
|
|
16
|
+
'notis-apps',
|
|
17
|
+
'notis-query',
|
|
18
|
+
'notis-cli',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
export function resolveBundledBaseSkillsRoot({ resourcesPath = process.resourcesPath } = {}) {
|
|
24
|
+
const sourceCheckout = resolve(HERE, '../../../../server/skills');
|
|
25
|
+
const packaged = resolve(HERE, '../../dist/base-skills');
|
|
26
|
+
const desktopResource = resourcesPath ? join(resourcesPath, 'base-skills') : null;
|
|
27
|
+
// Electron Packager copies each extraResource directory under its basename,
|
|
28
|
+
// so the three skill folders sit directly in process.resourcesPath.
|
|
29
|
+
const desktopPackagerRoot = resourcesPath || null;
|
|
30
|
+
for (const candidate of [sourceCheckout, packaged, desktopResource, desktopPackagerRoot]) {
|
|
31
|
+
if (
|
|
32
|
+
candidate
|
|
33
|
+
&& BASE_SKILL_NAMES.every((name) => existsSync(join(candidate, name, 'SKILL.md')))
|
|
34
|
+
) {
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return packaged;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getBaseSkillPaths({ home = homedir(), userId = null } = {}) {
|
|
42
|
+
const notisRoot = join(home, '.notis', 'skills');
|
|
43
|
+
const targetRoots = [
|
|
44
|
+
join(home, '.agents', 'skills'),
|
|
45
|
+
join(home, '.codex', 'skills'),
|
|
46
|
+
join(home, '.cursor', 'skills'),
|
|
47
|
+
join(home, '.claude', 'skills'),
|
|
48
|
+
];
|
|
49
|
+
if (userId) {
|
|
50
|
+
targetRoots.push(join(notisRoot, 'users', userId, 'skills'));
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
baseRoot: join(notisRoot, 'base'),
|
|
54
|
+
backupRoot: join(notisRoot, 'backups'),
|
|
55
|
+
targetRoots,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sameLinkTarget(linkPath, expectedTarget) {
|
|
60
|
+
try {
|
|
61
|
+
if (!lstatSync(linkPath).isSymbolicLink()) return false;
|
|
62
|
+
return resolve(dirname(linkPath), readlinkSync(linkPath)) === resolve(expectedTarget);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function backupConflict(linkPath, backupRoot, targetLabel, skillName, now) {
|
|
69
|
+
const backupPath = join(backupRoot, String(now), targetLabel, skillName);
|
|
70
|
+
mkdirSync(dirname(backupPath), { recursive: true, mode: 0o700 });
|
|
71
|
+
renameSync(linkPath, backupPath);
|
|
72
|
+
return backupPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function replaceDirectory(source, destination) {
|
|
76
|
+
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
77
|
+
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
|
78
|
+
const previous = `${destination}.previous-${process.pid}-${Date.now()}`;
|
|
79
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
80
|
+
cpSync(source, temporary, { recursive: true });
|
|
81
|
+
let movedPrevious = false;
|
|
82
|
+
try {
|
|
83
|
+
if (existsSync(destination)) {
|
|
84
|
+
renameSync(destination, previous);
|
|
85
|
+
movedPrevious = true;
|
|
86
|
+
}
|
|
87
|
+
renameSync(temporary, destination);
|
|
88
|
+
if (movedPrevious) rmSync(previous, { recursive: true, force: true });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
91
|
+
if (movedPrevious && !existsSync(destination) && existsSync(previous)) {
|
|
92
|
+
renameSync(previous, destination);
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Install the three CLI-owned system skills and expose them to every supported
|
|
100
|
+
* local agent. This intentionally does not consult the account sync setting:
|
|
101
|
+
* that setting controls only Electron's repeating account-skill refresh.
|
|
102
|
+
*/
|
|
103
|
+
export function reconcileBaseSkills({
|
|
104
|
+
home = homedir(),
|
|
105
|
+
userId = null,
|
|
106
|
+
sourceRoot = resolveBundledBaseSkillsRoot(),
|
|
107
|
+
now = Date.now(),
|
|
108
|
+
} = {}) {
|
|
109
|
+
const paths = getBaseSkillPaths({ home, userId });
|
|
110
|
+
const result = {
|
|
111
|
+
installed: 0,
|
|
112
|
+
linked: 0,
|
|
113
|
+
unchanged: 0,
|
|
114
|
+
backups: [],
|
|
115
|
+
skills: [...BASE_SKILL_NAMES],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const name of BASE_SKILL_NAMES) {
|
|
119
|
+
const source = join(sourceRoot, name);
|
|
120
|
+
if (!existsSync(join(source, 'SKILL.md'))) {
|
|
121
|
+
throw new Error(`Bundled base skill is missing: ${name}`);
|
|
122
|
+
}
|
|
123
|
+
replaceDirectory(source, join(paths.baseRoot, name));
|
|
124
|
+
result.installed += 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const targetRoot of paths.targetRoots) {
|
|
128
|
+
mkdirSync(targetRoot, { recursive: true, mode: 0o700 });
|
|
129
|
+
const targetLabel = relative(home, targetRoot).replaceAll('/', '_') || 'home';
|
|
130
|
+
for (const name of BASE_SKILL_NAMES) {
|
|
131
|
+
const skillTarget = join(paths.baseRoot, name);
|
|
132
|
+
const linkPath = join(targetRoot, name);
|
|
133
|
+
if (sameLinkTarget(linkPath, skillTarget)) {
|
|
134
|
+
result.unchanged += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (existsSync(linkPath) || (() => {
|
|
138
|
+
try { lstatSync(linkPath); return true; } catch { return false; }
|
|
139
|
+
})()) {
|
|
140
|
+
// sameLinkTarget already accepted links owned by this installer. Every
|
|
141
|
+
// other path, including a user-created symlink, is a conflict whose
|
|
142
|
+
// identity and target must remain recoverable.
|
|
143
|
+
result.backups.push(backupConflict(linkPath, paths.backupRoot, targetLabel, name, now));
|
|
144
|
+
}
|
|
145
|
+
const relativeTarget = relative(targetRoot, skillTarget) || '.';
|
|
146
|
+
symlinkSync(
|
|
147
|
+
process.platform === 'win32' ? skillTarget : relativeTarget,
|
|
148
|
+
linkPath,
|
|
149
|
+
process.platform === 'win32' ? 'junction' : undefined,
|
|
150
|
+
);
|
|
151
|
+
result.linked += 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function reconcileBaseSkillsBestEffort(options = {}) {
|
|
159
|
+
try {
|
|
160
|
+
return reconcileBaseSkills(options);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (process.env.NOTIS_DEBUG_BASE_SKILLS === '1') {
|
|
163
|
+
process.stderr.write(`[notis] Base skill reconciliation failed: ${error.message}\n`);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which published CLI build a profile should run.
|
|
3
|
+
*
|
|
4
|
+
* The npm tag has to be chosen before the CLI starts, and the CLI only learns
|
|
5
|
+
* which Notis it talks to after it reads the profile — so a single documented
|
|
6
|
+
* install command can never be right for both environments on its own. The
|
|
7
|
+
* deployment answers the question at login (`notis_cli_channel` in the CLI
|
|
8
|
+
* protected-resource metadata), the answer is pinned on the profile, and every
|
|
9
|
+
* later run re-executes the matching build. `@notis_ai/cli@latest` therefore
|
|
10
|
+
* stays the one command worth documenting, including for beta accounts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const RELEASE_CHANNELS = ['stable', 'beta'];
|
|
14
|
+
const CHANNEL_TAGS = { stable: 'latest', beta: 'beta' };
|
|
15
|
+
export const CLI_PACKAGE_NAME = '@notis_ai/cli';
|
|
16
|
+
// Set on the child so a build that disagrees about its own channel — a bad
|
|
17
|
+
// version string, a half-published tag — cannot bounce the process forever.
|
|
18
|
+
export const CHANNEL_SWITCH_ENV = 'NOTIS_CLI_CHANNEL_SWITCHED';
|
|
19
|
+
export const CHANNEL_DISABLE_ENV = 'NOTIS_CLI_AUTO_CHANNEL';
|
|
20
|
+
|
|
21
|
+
export function isReleaseChannel(value) {
|
|
22
|
+
return RELEASE_CHANNELS.includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function packageTagForChannel(channel) {
|
|
26
|
+
return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cliCommandForChannel(channel) {
|
|
30
|
+
return `npx --package ${CLI_PACKAGE_NAME}@${packageTagForChannel(channel)} -- notis`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The channel of the build that is currently running.
|
|
35
|
+
*
|
|
36
|
+
* The publish pipeline stamps beta releases as prereleases
|
|
37
|
+
* (`0.2.0-beta.129.1`) and production releases as plain semver (`0.2.10`), so
|
|
38
|
+
* the manifest version is the only channel marker that cannot drift from what
|
|
39
|
+
* npm actually served.
|
|
40
|
+
*/
|
|
41
|
+
export function channelFromVersion(version) {
|
|
42
|
+
return String(version || '').includes('-') ? 'beta' : 'stable';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The channel a profile is pinned to.
|
|
47
|
+
*
|
|
48
|
+
* `channel` is written at login from the deployment's own metadata. The
|
|
49
|
+
* `beta` flag and the endpoint host are the fallbacks that let a profile
|
|
50
|
+
* authorized by an older CLI resolve without a second login.
|
|
51
|
+
*/
|
|
52
|
+
export function channelFromProfile(profile = {}) {
|
|
53
|
+
if (isReleaseChannel(profile.channel)) {
|
|
54
|
+
return profile.channel;
|
|
55
|
+
}
|
|
56
|
+
if (profile.beta === true) return 'beta';
|
|
57
|
+
if (profile.beta === false) return 'stable';
|
|
58
|
+
for (const candidate of [profile.oauth_api_base, profile.api_base]) {
|
|
59
|
+
if (typeof candidate !== 'string' || !candidate) continue;
|
|
60
|
+
try {
|
|
61
|
+
const { hostname } = new URL(candidate);
|
|
62
|
+
if (hostname === 'api-beta.notis.ai') return 'beta';
|
|
63
|
+
if (hostname === 'api.notis.ai') return 'stable';
|
|
64
|
+
} catch {
|
|
65
|
+
// A malformed endpoint says nothing about the channel.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A profile served by `./dev.sh` runs the CLI from that worktree on purpose.
|
|
73
|
+
* Re-executing it into a published build would swap both the code under test
|
|
74
|
+
* and the credential the worktree minted.
|
|
75
|
+
*/
|
|
76
|
+
export function isDevManagedProfile(profile = {}) {
|
|
77
|
+
if (profile.dev_access_token || profile.dev_workspace_root) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (typeof profile.api_base !== 'string' || !profile.api_base) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const { hostname } = new URL(profile.api_base);
|
|
85
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A checkout run through `node bin/notis.js` is someone testing this source
|
|
93
|
+
* tree. Only an installed copy — one that npm placed under node_modules — may
|
|
94
|
+
* hand its invocation to a different published build.
|
|
95
|
+
*/
|
|
96
|
+
export function isInstalledPackage(moduleDirectory) {
|
|
97
|
+
return String(moduleDirectory || '').split(/[\\/]/).includes('node_modules');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether this process should hand over to another published build.
|
|
102
|
+
*
|
|
103
|
+
* Returns the reason in every case: the caller reports it under `--verbose`
|
|
104
|
+
* and the tests assert on it, so a switch that silently does not happen is
|
|
105
|
+
* still explainable.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveChannelSwitch({
|
|
108
|
+
runningVersion,
|
|
109
|
+
profile = {},
|
|
110
|
+
moduleDirectory = '',
|
|
111
|
+
env = process.env,
|
|
112
|
+
platform = process.platform,
|
|
113
|
+
} = {}) {
|
|
114
|
+
const runningChannel = channelFromVersion(runningVersion);
|
|
115
|
+
const targetChannel = channelFromProfile(profile);
|
|
116
|
+
const stay = (reason) => ({ switch: false, reason, runningChannel, targetChannel });
|
|
117
|
+
|
|
118
|
+
if (env[CHANNEL_SWITCH_ENV] === '1') return stay('already_switched');
|
|
119
|
+
if (env[CHANNEL_DISABLE_ENV] === '0') return stay('disabled');
|
|
120
|
+
if (!targetChannel) return stay('profile_channel_unknown');
|
|
121
|
+
if (targetChannel === runningChannel) return stay('channel_matches');
|
|
122
|
+
if (isDevManagedProfile(profile)) return stay('dev_managed_profile');
|
|
123
|
+
if (!isInstalledPackage(moduleDirectory)) return stay('source_checkout');
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
switch: true,
|
|
127
|
+
reason: 'channel_mismatch',
|
|
128
|
+
runningChannel,
|
|
129
|
+
targetChannel,
|
|
130
|
+
command: platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
131
|
+
args: ['--yes', '--package', `${CLI_PACKAGE_NAME}@${packageTagForChannel(targetChannel)}`, '--', 'notis'],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect that this CLI is running *as* a Notis agent rather than for a person.
|
|
3
|
+
*
|
|
4
|
+
* Hand-over is the one command where that distinction matters: an agent Notis
|
|
5
|
+
* is already running has no business handing the same work back to Notis, and
|
|
6
|
+
* nothing about the loop it would create is self-limiting.
|
|
7
|
+
*
|
|
8
|
+
* This is the polite half of the guard. The server refuses the same call unless
|
|
9
|
+
* it carries a purpose-scoped CLI/MCP OAuth token, which a delegated runtime
|
|
10
|
+
* never has -- that is the half that actually holds. What this adds is a clear
|
|
11
|
+
* message at the point of the mistake, instead of a permission error the agent
|
|
12
|
+
* will try to work around.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
17
|
+
|
|
18
|
+
// The cloud computer's root. Present only inside the user's Vercel sandbox.
|
|
19
|
+
const SANDBOX_ROOT = '/vercel/sandbox';
|
|
20
|
+
|
|
21
|
+
export function delegatedContextReason(env = process.env, { fileExists = existsSync } = {}) {
|
|
22
|
+
if (env.NOTIS_DELEGATED_CONTEXT === '1') {
|
|
23
|
+
// Set by Notis Desktop on every coding agent it spawns. Those runs use the
|
|
24
|
+
// user's own OAuth profile, so nothing else distinguishes them.
|
|
25
|
+
return 'this process was started by Notis as a delegated coding agent';
|
|
26
|
+
}
|
|
27
|
+
if (fileExists(SANDBOX_ROOT)) {
|
|
28
|
+
return 'this process is running on the Notis cloud computer';
|
|
29
|
+
}
|
|
30
|
+
if (env.NOTIS_AGENT === '1' && env.NOTIS_JWT) {
|
|
31
|
+
return 'this process is authenticated as a Notis agent, not as you';
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertNotDelegated(commandLabel, env = process.env) {
|
|
37
|
+
const reason = delegatedContextReason(env);
|
|
38
|
+
if (!reason) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new CliError({
|
|
42
|
+
code: 'handover_from_delegated_context',
|
|
43
|
+
message:
|
|
44
|
+
`\`notis ${commandLabel}\` hands work to a Notis agent, and ${reason}. ` +
|
|
45
|
+
'An agent cannot hand its own task back to Notis.',
|
|
46
|
+
exitCode: EXIT_CODES.usage,
|
|
47
|
+
hints: [
|
|
48
|
+
{ message: 'Run the hand-over from the terminal on your own machine.' },
|
|
49
|
+
{ message: 'If you are the agent: just do the work here, in this workspace.' },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tools that hand work to a Notis agent, by canonical name.
|
|
56
|
+
*
|
|
57
|
+
* `notis handover` is not the only way to reach these: `notis tools exec` takes
|
|
58
|
+
* any tool name, and `tools exec-parallel` takes a list of them. Guarding only
|
|
59
|
+
* the friendly command would leave the escape hatch it exists to wrap.
|
|
60
|
+
*/
|
|
61
|
+
export const HANDOVER_TOOL_NAMES = new Set(['LOCAL_NOTIS_HAND_OVER']);
|
|
62
|
+
|
|
63
|
+
export function assertToolNotDelegated(toolName, env = process.env) {
|
|
64
|
+
if (!HANDOVER_TOOL_NAMES.has(String(toolName || '').toUpperCase())) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
assertNotDelegated(`tools exec ${toolName}`, env);
|
|
68
|
+
}
|