@assuredloop/orca-bot-kit 0.27.0 → 0.28.0
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 +48 -15
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +3 -3
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +91 -10
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +95 -34
- package/src/commands.js +20 -11
- package/src/conversations.js +73 -3
- package/src/groom.js +27 -9
- package/src/health.js +18 -7
- package/src/hooks.js +7 -1
- package/src/init.js +36 -29
- package/src/launch.js +202 -18
- package/src/message.js +42 -2
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +16 -7
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/rules.js +5 -2
- package/src/skills.js +50 -34
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +68 -13
package/src/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every call the kit makes to Orca goes through here (ADR
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0046). Orca's CLI
|
|
2
2
|
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
3
|
// the parsing in one place.
|
|
4
4
|
//
|
|
@@ -106,6 +106,15 @@ export function orcaTrouble() {
|
|
|
106
106
|
if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
|
|
107
107
|
|
|
108
108
|
const answer = parse(asked.stdout);
|
|
109
|
+
// A CLI that dies with no answer has not reached the app at all: it can fail
|
|
110
|
+
// on its own, as on a current folder macOS will not let it read, while Orca
|
|
111
|
+
// runs (#556). So what it said is passed on, and nobody is sent to start Orca.
|
|
112
|
+
if (answer === undefined && asked.status !== 0) {
|
|
113
|
+
const exit = asked.status ?? `signal ${asked.signal}`;
|
|
114
|
+
return `the Orca CLI at ${orcaCli()} failed before it answered status (exit ${exit}; `
|
|
115
|
+
+ `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}). `
|
|
116
|
+
+ 'Fix what it says and run the command again, or point OBK_ORCA at another Orca CLI.';
|
|
117
|
+
}
|
|
109
118
|
// Orca knowing what is wrong beats the kit guessing, so its own words go
|
|
110
119
|
// first when it gave any.
|
|
111
120
|
if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
|
|
@@ -319,7 +328,7 @@ const CLIENT_KILL_MS = 3000;
|
|
|
319
328
|
*
|
|
320
329
|
* Orca's window re-reads only when its runtime says the projects changed, and
|
|
321
330
|
* `setup-update` and `setup-delete` do not say so. `project.update` with no
|
|
322
|
-
* changes does, and Orca's CLI does not offer it (ADR
|
|
331
|
+
* changes does, and Orca's CLI does not offer it (ADR 0046). The caller prints
|
|
323
332
|
* `RELOAD_LINE` either way. It does not take a removed project out of the
|
|
324
333
|
* sidebar: `reloadWindow` does.
|
|
325
334
|
*/
|
|
@@ -330,7 +339,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
|
|
|
330
339
|
* runtime's answer, or undefined when there is none to be had.
|
|
331
340
|
*
|
|
332
341
|
* It goes through Orca's own runtime client out of the installed app, run by
|
|
333
|
-
* Orca's binary the way its `bin/orca` runs its CLI (ADR
|
|
342
|
+
* Orca's binary the way its `bin/orca` runs its CLI (ADR 0046). None of that is
|
|
334
343
|
* Orca's published interface, so anything that goes wrong is a quiet
|
|
335
344
|
* undefined: it never throws and is never tried twice.
|
|
336
345
|
*/
|
|
@@ -367,7 +376,7 @@ const RELOAD_KILL_MS = 5000;
|
|
|
367
376
|
|
|
368
377
|
/**
|
|
369
378
|
* Have Orca's window force-reload itself, after a project was removed: true
|
|
370
|
-
* only when it did (#343, ADR
|
|
379
|
+
* only when it did (#343, ADR 0046).
|
|
371
380
|
*
|
|
372
381
|
* The window keeps a removed project in its sidebar until it is rebuilt, and
|
|
373
382
|
* `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
|
|
@@ -524,7 +533,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
524
533
|
* Whether the rows of a rendered screen hold a question of the harness's own.
|
|
525
534
|
*
|
|
526
535
|
* Every one seen is a numbered list of choices with the harness's pointer on
|
|
527
|
-
* one (tech notes, section 1; ADR
|
|
536
|
+
* one (tech notes, section 1; ADR 0046). The same pointer starts the harness's
|
|
528
537
|
* input line and its echo of the user's past turns, and the input line is the
|
|
529
538
|
* lowest of them whenever it is on screen, so the lowest pointer row is asked
|
|
530
539
|
* about: a question counts while it stands in the input line's place, or
|
|
@@ -730,7 +739,7 @@ export const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
730
739
|
*
|
|
731
740
|
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
732
741
|
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
733
|
-
* (#298, ADR
|
|
742
|
+
* (#298, ADR 0046). `readMs` bounds each call: a number, or a function that
|
|
734
743
|
* says what is left of a wait, asked before each one (#498). A `ps` that runs
|
|
735
744
|
* out of it ends the look, `late`, with no runtime asked after it.
|
|
736
745
|
*/
|
|
@@ -770,7 +779,7 @@ function frontByOrca(handle, readMs) {
|
|
|
770
779
|
* Who holds the terminal of the pane `ptyId`, as `ps` says it.
|
|
771
780
|
*
|
|
772
781
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
773
|
-
* gives that pid's terminal's foreground process group (ADR
|
|
782
|
+
* gives that pid's terminal's foreground process group (ADR 0046).
|
|
774
783
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
775
784
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
776
785
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
package/src/permissions.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// body beside the bots folder, commit. A matching allow rule is settled before
|
|
7
7
|
// that check (Claude Code's permissions docs), so the kit gives every bot a
|
|
8
8
|
// default set, spelled with its real CLI and bots folder, with nobody asked
|
|
9
|
-
// (#527, ADR
|
|
9
|
+
// (#527, ADR 0050). A rule beyond it is the user's yes, which
|
|
10
10
|
// `permission allow` keeps in bot.yaml's `allow`. The kit's code writes what
|
|
11
11
|
// `allow` holds into `.claude/settings.json` in the bot folder, beside its hook
|
|
12
12
|
// (ADR 0022). A model editing the file by hand may get the format wrong; code
|
|
@@ -24,7 +24,7 @@ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync
|
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
|
|
26
26
|
import { allowRules, leadsOutside } from './bot.js';
|
|
27
|
-
import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT } from './commands.js';
|
|
27
|
+
import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT, REMOVED } from './commands.js';
|
|
28
28
|
import { readSettings } from './hooks.js';
|
|
29
29
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
30
30
|
|
|
@@ -36,11 +36,10 @@ const CODEX_RULES = '.codex/rules';
|
|
|
36
36
|
const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* The rules every bot is given (ADR
|
|
40
|
-
* and none kept back for the user's yes (ADR
|
|
39
|
+
* The rules every bot is given (ADR 0050): each of the kit's default commands,
|
|
40
|
+
* and none kept back for the user's yes (ADR 0051), narrowed to this bots folder and
|
|
41
41
|
* spelled as the kit prints them, which is what a bot runs; reading a long
|
|
42
|
-
* message; committing
|
|
43
|
-
* run. `//` is Claude's form for an absolute path.
|
|
42
|
+
* message; and committing. `//` is Claude's form for an absolute path.
|
|
44
43
|
*/
|
|
45
44
|
export function defaultRules(bots, cli = ownCli()) {
|
|
46
45
|
const kit = shellWord(cli);
|
|
@@ -50,19 +49,27 @@ export function defaultRules(bots, cli = ownCli()) {
|
|
|
50
49
|
`Read(/${bots}.messages/**)`,
|
|
51
50
|
'Bash(git add:*)',
|
|
52
51
|
'Bash(git commit:*)',
|
|
53
|
-
'Bash(orca orchestration check --run:*)',
|
|
54
52
|
];
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
56
|
+
* Orca's check, which the set held until #564: the kit's rules tell a bot to
|
|
57
|
+
* read its mail with `obk message check`, never with the bare check, which
|
|
58
|
+
* leaves it unread (ADR 0042).
|
|
59
|
+
*/
|
|
60
|
+
const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The rules that were defaults once and are not now, that the bot's `allow`
|
|
64
|
+
* holds: those of the commands kept back (#548, #615) or gone from the kit (#555),
|
|
65
|
+
* spelled as `defaultRules` spelled them, and Orca's check (#564). The kit
|
|
66
|
+
* cannot tell its own old entry from a user's yes to the same rule, so it
|
|
67
|
+
* names them and leaves them (ADR 0051, ADR 0050).
|
|
62
68
|
*/
|
|
63
69
|
export function heldRules(bots, home, bot, cli = ownCli()) {
|
|
64
70
|
const allowed = allowOf(home, bot);
|
|
65
|
-
return NO_LONGER_DEFAULT.map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
|
|
71
|
+
return [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
|
|
72
|
+
.concat(ORCA_CHECK)
|
|
66
73
|
.filter((rule) => allowed.includes(rule));
|
|
67
74
|
}
|
|
68
75
|
|
|
@@ -220,9 +227,16 @@ export const runsOnCodex = (bot) => bot.harness === 'codex'
|
|
|
220
227
|
*/
|
|
221
228
|
function missingDefaults(bots, home, bot) {
|
|
222
229
|
const allowed = allowOf(home, bot);
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
|
|
230
|
+
return botDefaults(bots, bot).filter((rule) => !allowed.includes(rule));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The default rules a bot on Claude or Codex is given, in the default order:
|
|
235
|
+
* what `bot create` writes into a new bot's bot.yaml (#595).
|
|
236
|
+
*/
|
|
237
|
+
export function botDefaults(bots, bot) {
|
|
238
|
+
if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
|
|
239
|
+
return defaultRules(bots).filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined);
|
|
226
240
|
}
|
|
227
241
|
|
|
228
242
|
/**
|
|
@@ -270,7 +284,7 @@ export function refuseNoCodexForm(bot, rules) {
|
|
|
270
284
|
}
|
|
271
285
|
|
|
272
286
|
/**
|
|
273
|
-
* Add the default rules the bot is not allowed yet to its `allow` (ADR
|
|
287
|
+
* Add the default rules the bot is not allowed yet to its `allow` (ADR 0050),
|
|
274
288
|
* then write what it is allowed into the files of the harnesses it runs on:
|
|
275
289
|
* one entry per file, Claude's first, each with `defaults`, the rules this run
|
|
276
290
|
* added, and `held`, the rules of commands no longer in the set that the bot
|
package/src/pi.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Whether Pi is ready to start a session: it runs, it can use a model, and it
|
|
2
|
+
// can use the one the session names (#578).
|
|
3
|
+
//
|
|
4
|
+
// The kit never handles Pi's credentials: the user signs in to Pi on their own,
|
|
5
|
+
// and the kit only asks Pi what it can use. `pi auth check` is not asked, since
|
|
6
|
+
// it does not know a provider an extension of Pi's brings, such as Cursor's;
|
|
7
|
+
// `pi --list-models` lists the models Pi can use, those included (tech notes,
|
|
8
|
+
// section 6).
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
/** How long each question to Pi is given. Each took about 0.3 s on Pi 1.1.0. */
|
|
13
|
+
const ASK_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
/** Pi's thinking levels, which a model name may carry after a colon. */
|
|
16
|
+
const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why Pi cannot start these sessions, or undefined when it can. `models` are
|
|
20
|
+
* the models they name; a session that names none takes Pi's own default.
|
|
21
|
+
*/
|
|
22
|
+
export function piTrouble(models) {
|
|
23
|
+
const version = ask(['--version']);
|
|
24
|
+
if (version.error !== undefined || version.status !== 0) {
|
|
25
|
+
const why = version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
|
|
26
|
+
return `Pi does not run here (${why}), so no Pi session was started. Install Pi, then run this again.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const any = ask(['--list-models']);
|
|
30
|
+
if (any.error !== undefined || any.status !== 0 || /^No models available\b/m.test(any.stdout ?? '')) {
|
|
31
|
+
return 'Pi has no model it can use: it is signed in to no provider. Sign in to Pi yourself (start pi and use /login, or set a provider\'s API key), then run this again. The kit never handles Pi\'s credentials.';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const model of new Set(models)) {
|
|
35
|
+
const found = ask(['--list-models', withoutThinking(model)]);
|
|
36
|
+
if (found.error !== undefined || found.status !== 0) {
|
|
37
|
+
const why = found.error?.message ?? ((found.stderr ?? '').trim() || `it exited with ${found.status ?? found.signal}`);
|
|
38
|
+
return `Pi cannot say whether it can use the model ${model}: pi --list-models ${withoutThinking(model)} failed (${why}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
|
|
39
|
+
}
|
|
40
|
+
// Pi says it has no model at all before it searches, so the answer may be
|
|
41
|
+
// that one too, when a provider dropped out since the question above.
|
|
42
|
+
if (/^No models (matching|available)\b/m.test(found.stdout ?? '')) {
|
|
43
|
+
return `Pi cannot use the model ${model}: pi --list-models lists none matching it. Sign in to its provider in Pi yourself, or give the session a model Pi lists.`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The model without a trailing `:<thinking level>`, which `--list-models` does not match. */
|
|
50
|
+
function withoutThinking(model) {
|
|
51
|
+
const at = model.lastIndexOf(':');
|
|
52
|
+
return at > 0 && THINKING.includes(model.slice(at + 1)) ? model.slice(0, at) : model;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ask = (args) => spawnSync('pi', args, { encoding: 'utf8', timeout: ASK_MS });
|
package/src/rules.js
CHANGED
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { parse } from 'yaml';
|
|
22
22
|
|
|
23
|
-
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
23
|
+
import { botDir, botNames, displayName, readBot, refuseOutside } from './bot.js';
|
|
24
24
|
import { ownCli, shellWord } from './launch.js';
|
|
25
25
|
|
|
26
26
|
/** The kit's own units, inside the installed package. */
|
|
@@ -85,6 +85,7 @@ export function buildAgents(bots, home, bot) {
|
|
|
85
85
|
|
|
86
86
|
let state;
|
|
87
87
|
try {
|
|
88
|
+
refuseOutside(home, file);
|
|
88
89
|
state = writeBlock(file, built.body);
|
|
89
90
|
} catch (error) {
|
|
90
91
|
return { bot: bot.name, file, state: 'conflict', trouble: error.message };
|
|
@@ -416,7 +417,9 @@ export function agentsTrouble(bots, home, bot) {
|
|
|
416
417
|
/** Whether two paths are the same file in the end. One that leads nowhere is not. */
|
|
417
418
|
function sameFile(one, other) {
|
|
418
419
|
try {
|
|
419
|
-
|
|
420
|
+
// Native, as in leadsOutside: the JS one takes `hop/..` away before `hop`
|
|
421
|
+
// is followed.
|
|
422
|
+
return realpathSync.native(one) === realpathSync.native(other);
|
|
420
423
|
} catch {
|
|
421
424
|
return false;
|
|
422
425
|
}
|
package/src/skills.js
CHANGED
|
@@ -24,11 +24,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
24
24
|
import { parse, parseDocument, stringify } from 'yaml';
|
|
25
25
|
|
|
26
26
|
import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
27
|
-
import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
|
|
27
|
+
import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
|
|
28
28
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
29
|
-
import {
|
|
29
|
+
import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
30
30
|
import { listIn } from './rules.js';
|
|
31
|
-
import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
|
|
31
|
+
import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
|
|
32
32
|
|
|
33
33
|
/** The kit's own skills, inside the installed package. */
|
|
34
34
|
const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
|
|
@@ -84,25 +84,28 @@ export function addSkill(bots, bot, ref) {
|
|
|
84
84
|
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const doc = parseDocument(was);
|
|
91
|
-
const listed = doc.get('skills', true);
|
|
92
|
-
if (listed?.items === undefined) {
|
|
93
|
-
// No list to add to: an empty `skills:`, or no skills key at all.
|
|
94
|
-
doc.set('skills', [ref]);
|
|
95
|
-
} else {
|
|
96
|
-
listed.flow = false;
|
|
97
|
-
doc.addIn(['skills'], ref);
|
|
98
|
-
}
|
|
87
|
+
return withBotTurn(home, () => {
|
|
88
|
+
if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
|
|
89
|
+
refuseOutside(home, file);
|
|
99
90
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
const was = readFileSync(file, 'utf8');
|
|
92
|
+
const doc = parseDocument(was);
|
|
93
|
+
const listed = doc.get('skills', true);
|
|
94
|
+
if (listed?.items === undefined) {
|
|
95
|
+
// No list to add to: an empty `skills:`, or no skills key at all.
|
|
96
|
+
doc.set('skills', [ref]);
|
|
97
|
+
} else {
|
|
98
|
+
listed.flow = false;
|
|
99
|
+
doc.addIn(['skills'], ref);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const text = doc.toString(YAML_OUT);
|
|
103
|
+
if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
|
|
104
|
+
throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
|
|
105
|
+
}
|
|
106
|
+
writeWhole(file, text);
|
|
107
|
+
return { bot, home, skill: ref, state: 'added' };
|
|
108
|
+
});
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
/**
|
|
@@ -118,19 +121,22 @@ export function removeSkill(bots, bot, ref) {
|
|
|
118
121
|
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
return withBotTurn(home, () => {
|
|
125
|
+
const index = listIn(file, 'skills').indexOf(ref);
|
|
126
|
+
if (index === -1) return { bot, home, skill: ref, state: 'absent' };
|
|
127
|
+
refuseOutside(home, file);
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
const was = readFileSync(file, 'utf8');
|
|
130
|
+
const doc = parseDocument(was);
|
|
131
|
+
doc.deleteIn(['skills', index]);
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
const text = doc.toString(YAML_OUT);
|
|
134
|
+
if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
|
|
135
|
+
throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
|
|
136
|
+
}
|
|
137
|
+
writeWhole(file, text);
|
|
138
|
+
return { bot, home, skill: ref, state: 'removed' };
|
|
139
|
+
});
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
/**
|
|
@@ -472,7 +478,10 @@ function tellSessions(home, bot, linked) {
|
|
|
472
478
|
const told = { session: session.name, harness };
|
|
473
479
|
const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
|
|
474
480
|
if (tab === undefined) return { ...told, state: 'not-up' };
|
|
475
|
-
|
|
481
|
+
// Skills reach a Pi session in a later slice (#547), and nothing is typed
|
|
482
|
+
// into its tab before then.
|
|
483
|
+
if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
|
|
484
|
+
if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
|
|
476
485
|
let turn;
|
|
477
486
|
try {
|
|
478
487
|
// The session's turn for a line (#482), for the look and its line. Codex
|
|
@@ -564,7 +573,14 @@ function follow(bots, ref, sources) {
|
|
|
564
573
|
}
|
|
565
574
|
const wrong = wrongClone(bots, source);
|
|
566
575
|
if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
|
|
567
|
-
|
|
576
|
+
// The pinned clone is the only place a source's skill comes from. A path,
|
|
577
|
+
// a skill name or a link the repo committed can lead out of it (#571).
|
|
578
|
+
const inClone = path.join(skillsIn(bots, source), after);
|
|
579
|
+
const real = leavesClone(bots, named, inClone);
|
|
580
|
+
if (real !== undefined) {
|
|
581
|
+
throw new Error(`${ref} leads outside the clone of the source ${named}, to ${real}, and a skill from a source comes only from the version skills.yaml pins for it. Name a skill inside ${cloneDir(bots, named)} with no link out of it.`);
|
|
582
|
+
}
|
|
583
|
+
return inClone;
|
|
568
584
|
},
|
|
569
585
|
common: () => path.join(bots, 'skills', ref),
|
|
570
586
|
// The user's own spelling: absolute, at their home, or read from the bots
|
package/src/sources.js
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
// file, so the difference is theirs to see.
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
17
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { parseDocument } from 'yaml';
|
|
20
20
|
|
|
21
|
-
import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
|
|
21
|
+
import { changesExactly, leadsOutside, requireBotsFolder, YAML_OUT } from './bot.js';
|
|
22
22
|
import { ownCli, shellWord } from './launch.js';
|
|
23
23
|
|
|
24
24
|
/** The user's file that lists where skills come from. */
|
|
@@ -65,6 +65,9 @@ export function addSource(bots, { name, repo, ref, path: inside }) {
|
|
|
65
65
|
if (TAKEN.includes(name)) {
|
|
66
66
|
throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
|
|
67
67
|
}
|
|
68
|
+
if (inside !== undefined && !staysInRepo(inside)) {
|
|
69
|
+
throw new Error(`${inside} cannot be the path of the source ${name}: it leads out of the repo, and a path is a subfolder inside it, such as skills.`);
|
|
70
|
+
}
|
|
68
71
|
|
|
69
72
|
const file = path.join(bots, SKILLS_YAML);
|
|
70
73
|
// Read through the same reader every other command uses, so a file this one
|
|
@@ -151,7 +154,6 @@ function bring(bots, source, moving) {
|
|
|
151
154
|
const wrong = wrongClone(bots, source);
|
|
152
155
|
if (wrong !== undefined) {
|
|
153
156
|
if (!moving) throw new Error(wrong);
|
|
154
|
-
rmSync(dir, { recursive: true, force: true });
|
|
155
157
|
return clone(bots, source, source.ref, false);
|
|
156
158
|
}
|
|
157
159
|
|
|
@@ -174,11 +176,16 @@ function bring(bots, source, moving) {
|
|
|
174
176
|
* Clone a source and check it out out of the way, and put it in place only once
|
|
175
177
|
* both have worked. `<name>.fetching` is where it happens; a run that cannot
|
|
176
178
|
* finish leaves that behind and not a source.
|
|
179
|
+
*
|
|
180
|
+
* A clone already there, from a repository the file no longer names, stays
|
|
181
|
+
* where it is until the new one is ready (#568). It may hold the user's own
|
|
182
|
+
* files, and a replacement that fails must not cost them.
|
|
177
183
|
*/
|
|
178
184
|
function clone(bots, source, rev, recorded) {
|
|
179
185
|
mkdirSync(sourcesDir(bots), { recursive: true });
|
|
180
186
|
const dir = cloneDir(bots, source.name);
|
|
181
187
|
const fetching = `${dir}.fetching`;
|
|
188
|
+
const replaced = `${dir}.replaced`;
|
|
182
189
|
|
|
183
190
|
try {
|
|
184
191
|
rmSync(fetching, { recursive: true, force: true });
|
|
@@ -186,7 +193,25 @@ function clone(bots, source, rev, recorded) {
|
|
|
186
193
|
// repository and never one of git's own options, whatever it begins with.
|
|
187
194
|
git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
|
|
188
195
|
checkout(fetching, source, rev, recorded);
|
|
189
|
-
|
|
196
|
+
if (existsSync(dir)) {
|
|
197
|
+
rmSync(replaced, { recursive: true, force: true });
|
|
198
|
+
renameSync(dir, replaced);
|
|
199
|
+
try {
|
|
200
|
+
renameSync(fetching, dir);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
renameSync(replaced, dir);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
// The new clone is in place, so the update has worked. An old copy that
|
|
206
|
+
// will not go stays beside it, where health names it as a leftover.
|
|
207
|
+
try {
|
|
208
|
+
rmSync(replaced, { recursive: true, force: true });
|
|
209
|
+
} catch {
|
|
210
|
+
// Nothing to undo.
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
renameSync(fetching, dir);
|
|
214
|
+
}
|
|
190
215
|
} finally {
|
|
191
216
|
rmSync(fetching, { recursive: true, force: true });
|
|
192
217
|
}
|
|
@@ -250,6 +275,47 @@ function git(args, cwd, trouble) {
|
|
|
250
275
|
return result.stdout.trim();
|
|
251
276
|
}
|
|
252
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Whether a source's path, as written, names a folder inside its repo: not an
|
|
280
|
+
* absolute path, and no `..` that climbs above the top (#571). A link inside the
|
|
281
|
+
* clone can still lead out; `skills build` follows it before linking.
|
|
282
|
+
*/
|
|
283
|
+
const staysInRepo = (inside) => !path.isAbsolute(inside) && path.normalize(inside).split(path.sep)[0] !== '..';
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Where a source's skill at `dir` really leads when a link takes it, or anything
|
|
287
|
+
* in it, out of the source's clone; undefined when all of it stays inside (#571).
|
|
288
|
+
* A harness reads any file in a skill, so the whole folder is walked, through
|
|
289
|
+
* the links that stay inside as well.
|
|
290
|
+
*/
|
|
291
|
+
export function leavesClone(bots, name, dir) {
|
|
292
|
+
const clone = cloneDir(bots, name);
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
const walk = (at) => {
|
|
295
|
+
const real = leadsOutside(clone, at);
|
|
296
|
+
if (real !== undefined) return real;
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
const where = realpathSync.native(at);
|
|
300
|
+
// A folder reached twice, through a link back into the skill, is walked once.
|
|
301
|
+
if (seen.has(where) || !statSync(where).isDirectory()) return undefined;
|
|
302
|
+
seen.add(where);
|
|
303
|
+
entries = readdirSync(where);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// Nothing there: whatever links it says so.
|
|
306
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
|
|
307
|
+
// A folder the kit may not read can hold a link out, and nothing shows it.
|
|
308
|
+
throw new Error(`${name}: the kit could not read ${at} (${error.code ?? error.message}), so it cannot tell whether anything in the skill leads out of the source's clone, and it links nothing from there.`);
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
const found = walk(path.join(at, entry));
|
|
312
|
+
if (found !== undefined) return found;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
};
|
|
316
|
+
return walk(dir);
|
|
317
|
+
}
|
|
318
|
+
|
|
253
319
|
/** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
|
|
254
320
|
export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
|
|
255
321
|
|
|
@@ -331,6 +397,9 @@ export function readSources(file) {
|
|
|
331
397
|
if (source.path !== undefined && typeof source.path !== 'string') {
|
|
332
398
|
throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
|
|
333
399
|
}
|
|
400
|
+
if (source.path !== undefined && !staysInRepo(source.path)) {
|
|
401
|
+
throw new Error(`${file}: the source ${name} has the path ${source.path}, which leads out of the repo, and a path is a subfolder inside it, such as skills.`);
|
|
402
|
+
}
|
|
334
403
|
return source;
|
|
335
404
|
});
|
|
336
405
|
}
|