@notis_ai/cli 0.2.0-beta.136.1 → 0.2.0-beta.139.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 +38 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +16620 -0
- package/{skills → dist/base-skills}/notis-apps/SKILL.md +9 -6
- package/{skills → dist/base-skills}/notis-cli/SKILL.md +1 -1
- package/dist/base-skills/notis-query/SKILL.md +705 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +8 -0
- package/dist/skill-sync/index.js +1528 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/package.json +4 -1
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +16 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +23 -14
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/auth.js +16 -0
- package/src/command-specs/index.js +6 -0
- package/src/command-specs/onboarding.js +59 -2
- package/src/command-specs/skills.js +56 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/skill-sync/cloud-client.ts +96 -0
- package/src/runtime/skill-sync/index.ts +644 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +383 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +103 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/store-screenshot.js +6 -1
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +215 -0
- package/template/packages/sdk/src/config.ts +8 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const MAX_SEEN_HASHES = 500;
|
|
13
|
+
const MAX_PROMPT_CHARS = 12_000;
|
|
14
|
+
|
|
15
|
+
function hash(value) {
|
|
16
|
+
return createHash('sha256').update(String(value || '')).digest('hex').slice(0, 24);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function statePath(sessionId, home = homedir()) {
|
|
20
|
+
if (!sessionId) return null;
|
|
21
|
+
return join(home, '.notis', 'agent-memory-hooks', `${hash(sessionId)}.json`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readState(sessionId, home) {
|
|
25
|
+
const filePath = statePath(sessionId, home);
|
|
26
|
+
if (!filePath || !existsSync(filePath)) return { seen: [], pending: null };
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
29
|
+
return parsed && typeof parsed === 'object'
|
|
30
|
+
? {
|
|
31
|
+
seen: Array.isArray(parsed.seen) ? parsed.seen.filter((item) => typeof item === 'string') : [],
|
|
32
|
+
pending: parsed.pending && typeof parsed.pending === 'object' ? parsed.pending : null,
|
|
33
|
+
identity: parsed.identity && typeof parsed.identity === 'object' ? parsed.identity : null,
|
|
34
|
+
}
|
|
35
|
+
: { seen: [], pending: null };
|
|
36
|
+
} catch {
|
|
37
|
+
return { seen: [], pending: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inputIdentity(input) {
|
|
42
|
+
const profile = typeof input?.profile_name === 'string' ? input.profile_name : '';
|
|
43
|
+
const account = typeof input?.account_id === 'string' ? input.account_id : '';
|
|
44
|
+
const apiBase = typeof input?.api_base === 'string' ? input.api_base : '';
|
|
45
|
+
return profile && account && apiBase ? { profile, account, apiBase } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function identityMatches(left, right) {
|
|
49
|
+
return (!left && !right) || Boolean(
|
|
50
|
+
left && right
|
|
51
|
+
&& left.profile === right.profile
|
|
52
|
+
&& left.account === right.account
|
|
53
|
+
&& left.apiBase === right.apiBase,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stateForInput(sessionId, input, home) {
|
|
58
|
+
const state = readState(sessionId, home);
|
|
59
|
+
const identity = inputIdentity(input);
|
|
60
|
+
if (identityMatches(state.identity, identity)) return state;
|
|
61
|
+
return { seen: [], pending: null, identity };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeState(sessionId, state, home) {
|
|
65
|
+
const filePath = statePath(sessionId, home);
|
|
66
|
+
if (!filePath) return;
|
|
67
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
68
|
+
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
70
|
+
renameSync(temporaryPath, filePath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rememberPendingTurn(input, home = homedir()) {
|
|
74
|
+
const sessionId = input?.session_id;
|
|
75
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt.trim() : '';
|
|
76
|
+
if (!sessionId || !prompt || ['/', '!', '#'].includes(prompt[0])) return;
|
|
77
|
+
const state = stateForInput(sessionId, input, home);
|
|
78
|
+
state.pending = {
|
|
79
|
+
prompt: prompt.slice(0, MAX_PROMPT_CHARS),
|
|
80
|
+
turn_id: typeof input?.turn_id === 'string' ? input.turn_id : null,
|
|
81
|
+
cwd: typeof input?.cwd === 'string' ? input.cwd : null,
|
|
82
|
+
recorded_at: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
writeState(sessionId, state, home);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function pendingTurn(input, home = homedir()) {
|
|
88
|
+
const sessionId = input?.session_id;
|
|
89
|
+
const state = readState(sessionId, home);
|
|
90
|
+
const identity = inputIdentity(input);
|
|
91
|
+
if (!identityMatches(state.identity, identity)) {
|
|
92
|
+
if (sessionId) writeState(sessionId, { seen: [], pending: null, identity }, home);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (!state.pending) return null;
|
|
96
|
+
if (state.pending.turn_id && input?.turn_id && state.pending.turn_id !== input.turn_id) return null;
|
|
97
|
+
return state.pending;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function completePendingTurn(input, home = homedir()) {
|
|
101
|
+
const sessionId = input?.session_id;
|
|
102
|
+
if (!sessionId) return;
|
|
103
|
+
const filePath = statePath(sessionId, home);
|
|
104
|
+
if (!filePath || !existsSync(filePath)) return;
|
|
105
|
+
const state = readState(sessionId, home);
|
|
106
|
+
if (!state.pending) return;
|
|
107
|
+
state.pending = null;
|
|
108
|
+
writeState(sessionId, state, home);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function freshRecallItems(input, items, home = homedir()) {
|
|
112
|
+
const sessionId = input?.session_id;
|
|
113
|
+
if (!sessionId) return items;
|
|
114
|
+
const state = stateForInput(sessionId, input, home);
|
|
115
|
+
const seen = new Set(state.seen);
|
|
116
|
+
const fresh = [];
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const fingerprint = hash(item);
|
|
119
|
+
if (seen.has(fingerprint)) continue;
|
|
120
|
+
seen.add(fingerprint);
|
|
121
|
+
fresh.push(item);
|
|
122
|
+
}
|
|
123
|
+
state.seen = [...seen].slice(-MAX_SEEN_HASHES);
|
|
124
|
+
writeState(sessionId, state, home);
|
|
125
|
+
return fresh;
|
|
126
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const INSTRUCTIONS_PATH = join(HERE, '..', '..', 'skills', 'notis-cli', 'AGENT_INSTRUCTIONS.md');
|
|
17
|
+
const HOOK_BUNDLE_PATH = join(HERE, '..', '..', 'dist', 'agent-hooks', 'notis-agent-hook.mjs');
|
|
18
|
+
const START_MARKER = '<!-- notis-cli:instructions:start -->';
|
|
19
|
+
const END_MARKER = '<!-- notis-cli:instructions:end -->';
|
|
20
|
+
const LEGACY_SENTINEL = 'Use the Notis CLI (`npx --package @notis_ai/cli@latest -- notis ...`)';
|
|
21
|
+
const MANAGED_HOOK_MARKER = '--notis-managed-agent-hook';
|
|
22
|
+
const LEGACY_MANAGED_HOOK_MARKER = 'NOTIS_MANAGED_AGENT_HOOK=1';
|
|
23
|
+
|
|
24
|
+
export const AGENT_IDS = Object.freeze(['codex', 'claude-code']);
|
|
25
|
+
|
|
26
|
+
function atomicWrite(filePath, contents, mode = 0o600) {
|
|
27
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
28
|
+
const temporaryPath = `${filePath}.notis-${process.pid}-${Date.now()}.tmp`;
|
|
29
|
+
writeFileSync(temporaryPath, contents, { mode });
|
|
30
|
+
renameSync(temporaryPath, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readText(filePath) {
|
|
34
|
+
return existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function activeCodexInstructionsPath(home) {
|
|
38
|
+
const overridePath = join(home, '.codex', 'AGENTS.override.md');
|
|
39
|
+
if (existsSync(overridePath) && readText(overridePath).trim()) {
|
|
40
|
+
return overridePath;
|
|
41
|
+
}
|
|
42
|
+
return join(home, '.codex', 'AGENTS.md');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function upsertInstructionBlock(filePath, block) {
|
|
46
|
+
const existing = readText(filePath);
|
|
47
|
+
const managedBlockPattern = new RegExp(
|
|
48
|
+
`${START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
|
|
49
|
+
'g',
|
|
50
|
+
);
|
|
51
|
+
const managedBlocks = [...existing.matchAll(managedBlockPattern)];
|
|
52
|
+
let next;
|
|
53
|
+
let status;
|
|
54
|
+
|
|
55
|
+
if (managedBlocks.length) {
|
|
56
|
+
let replaced = false;
|
|
57
|
+
next = existing.replace(managedBlockPattern, () => {
|
|
58
|
+
if (replaced) return '';
|
|
59
|
+
replaced = true;
|
|
60
|
+
return block;
|
|
61
|
+
});
|
|
62
|
+
status = next === existing ? 'unchanged' : 'updated';
|
|
63
|
+
} else if (existing.includes(LEGACY_SENTINEL)) {
|
|
64
|
+
// An unmarked block belongs to the user or another installer. Do not risk
|
|
65
|
+
// rewriting adjacent personal instructions merely to take ownership of it.
|
|
66
|
+
return { path: filePath, status: 'already_present_unmanaged' };
|
|
67
|
+
} else {
|
|
68
|
+
const prefix = existing.trimEnd();
|
|
69
|
+
next = `${prefix ? `${prefix}\n\n` : ''}${block.trim()}\n`;
|
|
70
|
+
status = 'installed';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (status !== 'unchanged') {
|
|
74
|
+
atomicWrite(filePath, next);
|
|
75
|
+
}
|
|
76
|
+
return { path: filePath, status };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readJsonObject(filePath) {
|
|
80
|
+
if (!existsSync(filePath)) return {};
|
|
81
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
85
|
+
throw new Error('root value must be an object');
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`Refusing to modify invalid JSON at ${filePath}: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isManagedNotisHookCommand(command) {
|
|
94
|
+
if (typeof command !== 'string') return false;
|
|
95
|
+
if (
|
|
96
|
+
command.includes(MANAGED_HOOK_MARKER)
|
|
97
|
+
|| command.includes(LEGACY_MANAGED_HOOK_MARKER)
|
|
98
|
+
) return true;
|
|
99
|
+
if (
|
|
100
|
+
command.includes('.notis/agent-hooks/bin/notis-agent-hook')
|
|
101
|
+
&& /\sagent-(?:context|capture)\b/.test(command)
|
|
102
|
+
) return true;
|
|
103
|
+
// Supported legacy installers used either this package's bin/notis.js path
|
|
104
|
+
// or the exact public npx package. Do not claim arbitrary commands merely
|
|
105
|
+
// because they happen to use the same subcommand words.
|
|
106
|
+
return (
|
|
107
|
+
/(?:^|\s)(?:'[^']*|"[^"]*"|\S*)bin\/notis\.js(?:'|")?\s[^\n]*\sagent-(?:context|capture)\b/.test(command)
|
|
108
|
+
|| /@notis_ai\/cli@[^\s]+\s+--\s+notis\s[^\n]*\sagent-(?:context|capture)\b/.test(command)
|
|
109
|
+
|| /^\s*notis\s+(?:--[^\s]+\s+)*agent-(?:context|capture)\b/.test(command)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function objectHasManagedNotisHook(value) {
|
|
114
|
+
if (Array.isArray(value)) return value.some(objectHasManagedNotisHook);
|
|
115
|
+
if (!value || typeof value !== 'object') return false;
|
|
116
|
+
if (isManagedNotisHookCommand(value.command)) return true;
|
|
117
|
+
return Object.values(value).some(objectHasManagedNotisHook);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fileHasManagedNotisHook(filePath) {
|
|
121
|
+
try {
|
|
122
|
+
return objectHasManagedNotisHook(readJsonObject(filePath));
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function removePriorNotisPromptHooks(groups) {
|
|
129
|
+
if (!Array.isArray(groups)) return [];
|
|
130
|
+
const kept = [];
|
|
131
|
+
for (const group of groups) {
|
|
132
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
|
|
133
|
+
kept.push(group);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const hooks = group.hooks.filter((hook) => !isManagedNotisHookCommand(hook?.command));
|
|
137
|
+
if (hooks.length) kept.push({ ...group, hooks });
|
|
138
|
+
}
|
|
139
|
+
return kept;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function shellQuote(value) {
|
|
143
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function installManagedHookRuntime({
|
|
147
|
+
home = homedir(),
|
|
148
|
+
bundlePath = HOOK_BUNDLE_PATH,
|
|
149
|
+
nodePath = process.execPath,
|
|
150
|
+
platform = process.platform,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const bundle = readFileSync(bundlePath);
|
|
153
|
+
const digest = createHash('sha256').update(bundle).digest('hex');
|
|
154
|
+
const runtimePath = join(
|
|
155
|
+
home,
|
|
156
|
+
'.notis',
|
|
157
|
+
'agent-hooks',
|
|
158
|
+
'runtime',
|
|
159
|
+
digest,
|
|
160
|
+
'notis-agent-hook.mjs',
|
|
161
|
+
);
|
|
162
|
+
const existingDigest = existsSync(runtimePath)
|
|
163
|
+
? createHash('sha256').update(readFileSync(runtimePath)).digest('hex')
|
|
164
|
+
: null;
|
|
165
|
+
if (existingDigest !== digest) atomicWrite(runtimePath, bundle, 0o500);
|
|
166
|
+
chmodSync(runtimePath, 0o500);
|
|
167
|
+
|
|
168
|
+
const launcherPath = join(
|
|
169
|
+
home,
|
|
170
|
+
'.notis',
|
|
171
|
+
'agent-hooks',
|
|
172
|
+
'bin',
|
|
173
|
+
platform === 'win32' ? 'notis-agent-hook.cmd' : 'notis-agent-hook',
|
|
174
|
+
);
|
|
175
|
+
const launcher = platform === 'win32'
|
|
176
|
+
? [
|
|
177
|
+
'@echo off',
|
|
178
|
+
`if not exist "${nodePath}" (`,
|
|
179
|
+
' >&2 echo Notis memory hook runtime needs repair; run notis agents install again.',
|
|
180
|
+
' exit /b 1',
|
|
181
|
+
')',
|
|
182
|
+
`"${nodePath}" "${runtimePath}" %*`,
|
|
183
|
+
'',
|
|
184
|
+
].join('\r\n')
|
|
185
|
+
: [
|
|
186
|
+
'#!/bin/sh',
|
|
187
|
+
`if [ -x ${shellQuote(nodePath)} ]; then`,
|
|
188
|
+
` exec ${shellQuote(nodePath)} ${shellQuote(runtimePath)} "$@"`,
|
|
189
|
+
'fi',
|
|
190
|
+
`echo 'Notis memory hook runtime needs repair; run notis agents install again.' >&2`,
|
|
191
|
+
'exit 1',
|
|
192
|
+
'',
|
|
193
|
+
].join('\n');
|
|
194
|
+
if (readText(launcherPath) !== launcher) {
|
|
195
|
+
// Windows maps the owner write bit to the read-only file attribute. A
|
|
196
|
+
// launcher installed by an older CLI is deliberately read-only, so make
|
|
197
|
+
// our owned file replaceable before the atomic rename used for upgrades.
|
|
198
|
+
if (platform === 'win32' && existsSync(launcherPath)) {
|
|
199
|
+
chmodSync(launcherPath, 0o700);
|
|
200
|
+
}
|
|
201
|
+
atomicWrite(launcherPath, launcher, 0o500);
|
|
202
|
+
}
|
|
203
|
+
chmodSync(launcherPath, 0o500);
|
|
204
|
+
return { launcherPath, runtimePath, digest };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shellHookCommand(launcherPath, profileName, command, agentId = null, platform = process.platform) {
|
|
208
|
+
// Stored profile names follow a shell-safe grammar enforced by the CLI.
|
|
209
|
+
const agentFlag = agentId ? ` --agent ${agentId}` : '';
|
|
210
|
+
const quotedLauncher = platform === 'win32'
|
|
211
|
+
? `"${launcherPath}"`
|
|
212
|
+
: shellQuote(launcherPath);
|
|
213
|
+
return `${quotedLauncher} ${MANAGED_HOOK_MARKER} --profile ${profileName} --timeout-ms 30000 ${command}${agentFlag}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function contextHandler(agentId, profileName, launcherPath, platform) {
|
|
217
|
+
return {
|
|
218
|
+
type: 'command',
|
|
219
|
+
command: shellHookCommand(launcherPath, profileName, 'agent-context', null, platform),
|
|
220
|
+
timeout: 30,
|
|
221
|
+
statusMessage: 'Loading relevant Notis memory',
|
|
222
|
+
...(agentId === 'codex' ? { additionalContextLimit: 2500 } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function captureHandler(agentId, profileName, launcherPath, platform) {
|
|
227
|
+
return {
|
|
228
|
+
type: 'command',
|
|
229
|
+
command: shellHookCommand(launcherPath, profileName, 'agent-capture', agentId, platform),
|
|
230
|
+
timeout: 30,
|
|
231
|
+
statusMessage: 'Saving durable Notis context',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function upsertPromptHooks(
|
|
236
|
+
filePath,
|
|
237
|
+
agentId,
|
|
238
|
+
profileName,
|
|
239
|
+
enabled,
|
|
240
|
+
launcherPath = '',
|
|
241
|
+
platform = process.platform,
|
|
242
|
+
) {
|
|
243
|
+
const settings = readJsonObject(filePath);
|
|
244
|
+
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
245
|
+
? { ...settings.hooks }
|
|
246
|
+
: {};
|
|
247
|
+
let removedManagedHook = false;
|
|
248
|
+
for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
|
|
249
|
+
const previousGroups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
250
|
+
const groups = removePriorNotisPromptHooks(previousGroups);
|
|
251
|
+
if (JSON.stringify(groups) !== JSON.stringify(previousGroups)) removedManagedHook = true;
|
|
252
|
+
if (enabled) {
|
|
253
|
+
groups.push({ hooks: [event === 'Stop'
|
|
254
|
+
? captureHandler(agentId, profileName, launcherPath, platform)
|
|
255
|
+
: contextHandler(agentId, profileName, launcherPath, platform)] });
|
|
256
|
+
}
|
|
257
|
+
if (groups.length) hooks[event] = groups;
|
|
258
|
+
else delete hooks[event];
|
|
259
|
+
}
|
|
260
|
+
const next = { ...settings, hooks };
|
|
261
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
262
|
+
const existing = readText(filePath);
|
|
263
|
+
if (!enabled && !removedManagedHook) return { path: filePath, status: 'unchanged' };
|
|
264
|
+
if (existing === serialized) return { path: filePath, status: 'unchanged' };
|
|
265
|
+
atomicWrite(filePath, serialized);
|
|
266
|
+
return {
|
|
267
|
+
path: filePath,
|
|
268
|
+
status: enabled
|
|
269
|
+
? existsSync(filePath) && existing ? 'updated' : 'installed'
|
|
270
|
+
: 'removed',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function agentPaths(agentId, home) {
|
|
275
|
+
if (agentId === 'codex') {
|
|
276
|
+
return {
|
|
277
|
+
root: join(home, '.codex'),
|
|
278
|
+
instructions: activeCodexInstructionsPath(home),
|
|
279
|
+
hooks: join(home, '.codex', 'hooks.json'),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (agentId === 'claude-code') {
|
|
283
|
+
return {
|
|
284
|
+
root: join(home, '.claude'),
|
|
285
|
+
instructions: join(home, '.claude', 'CLAUDE.md'),
|
|
286
|
+
hooks: join(home, '.claude', 'settings.json'),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`Unsupported agent: ${agentId}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function shouldInstallLocalAgentSetup(env = process.env, home = homedir()) {
|
|
293
|
+
if (env.NOTIS_JWT || env.NOTIS_AGENT === '1' || env.NOTIS_DELEGATED_CONTEXT === '1') return false;
|
|
294
|
+
if (env.CONDUCTOR_IS_LOCAL === '0') return false;
|
|
295
|
+
if (existsSync('/vercel/sandbox')) return false;
|
|
296
|
+
return Boolean(home);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function installAgentSetup({
|
|
300
|
+
profileName,
|
|
301
|
+
agents = AGENT_IDS,
|
|
302
|
+
memoryHooks = true,
|
|
303
|
+
onlyExisting = false,
|
|
304
|
+
detectedAgents = null,
|
|
305
|
+
home = homedir(),
|
|
306
|
+
platform = process.platform,
|
|
307
|
+
} = {}) {
|
|
308
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(profileName || ''))) {
|
|
309
|
+
throw new Error('A valid authenticated Notis profile is required for agent setup');
|
|
310
|
+
}
|
|
311
|
+
const instructions = readFileSync(INSTRUCTIONS_PATH, 'utf-8').trim();
|
|
312
|
+
const results = [];
|
|
313
|
+
const shouldRepairManagedHooks = memoryHooks === true || (
|
|
314
|
+
memoryHooks === null
|
|
315
|
+
&& agents.some((agentId) => fileHasManagedNotisHook(agentPaths(agentId, home).hooks))
|
|
316
|
+
);
|
|
317
|
+
const hookRuntime = shouldRepairManagedHooks
|
|
318
|
+
? installManagedHookRuntime({ home, platform })
|
|
319
|
+
: null;
|
|
320
|
+
|
|
321
|
+
for (const agentId of agents) {
|
|
322
|
+
const paths = agentPaths(agentId, home);
|
|
323
|
+
const detected = Array.isArray(detectedAgents)
|
|
324
|
+
? detectedAgents.includes(agentId)
|
|
325
|
+
: existsSync(paths.root);
|
|
326
|
+
if (onlyExisting && !detected) {
|
|
327
|
+
results.push({ agent: agentId, status: 'not_detected' });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const result = {
|
|
331
|
+
agent: agentId,
|
|
332
|
+
instructions: upsertInstructionBlock(paths.instructions, instructions),
|
|
333
|
+
memory_hook: null,
|
|
334
|
+
};
|
|
335
|
+
if (memoryHooks === null && !fileHasManagedNotisHook(paths.hooks)) {
|
|
336
|
+
result.memory_hook = { path: paths.hooks, status: 'preserved' };
|
|
337
|
+
} else {
|
|
338
|
+
try {
|
|
339
|
+
result.memory_hook = upsertPromptHooks(
|
|
340
|
+
paths.hooks,
|
|
341
|
+
agentId,
|
|
342
|
+
profileName,
|
|
343
|
+
memoryHooks !== false,
|
|
344
|
+
hookRuntime?.launcherPath,
|
|
345
|
+
platform,
|
|
346
|
+
);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
result.memory_hook = {
|
|
349
|
+
path: paths.hooks,
|
|
350
|
+
status: 'error',
|
|
351
|
+
message: error instanceof Error ? error.message : String(error),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
results.push(result);
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function detectedAgentIds(home = homedir()) {
|
|
361
|
+
return AGENT_IDS.filter((agentId) => {
|
|
362
|
+
const root = agentPaths(agentId, home).root;
|
|
363
|
+
if (!existsSync(root)) return false;
|
|
364
|
+
try {
|
|
365
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
366
|
+
if (entries.some((entry) => entry.name !== 'skills')) return true;
|
|
367
|
+
const skillsRoot = join(root, 'skills');
|
|
368
|
+
if (!existsSync(skillsRoot)) return false;
|
|
369
|
+
// Base reconciliation intentionally creates every supported vendor root.
|
|
370
|
+
// A root containing only our three links is not evidence that the vendor
|
|
371
|
+
// itself is installed; any other skill entry is a genuine user signal.
|
|
372
|
+
return readdirSync(skillsRoot).some((name) => (
|
|
373
|
+
!['notis-apps', 'notis-query', 'notis-cli'].includes(name)
|
|
374
|
+
));
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function instructionTemplatePath() {
|
|
382
|
+
return INSTRUCTIONS_PATH;
|
|
383
|
+
}
|
|
@@ -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
|
+
}
|