@manyoars/oars 0.2.0 → 0.3.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 +132 -28
- package/dist/api.js +1 -1
- package/dist/bin.js +6 -29
- package/dist/cli.d.ts +6 -3
- package/dist/cli.js +367 -88
- package/dist/commands/keys.d.ts +31 -0
- package/dist/commands/keys.js +65 -0
- package/dist/commands/login.js +2 -2
- package/dist/errors.d.ts +13 -13
- package/dist/errors.js +28 -11
- package/dist/gate/checks.d.ts +68 -0
- package/dist/gate/checks.js +584 -0
- package/dist/gate/command.d.ts +14 -0
- package/dist/gate/command.js +61 -0
- package/dist/gate/hook-fill-main.d.ts +1 -0
- package/dist/gate/hook-fill-main.js +16 -0
- package/dist/gate/hook-fill.d.ts +38 -0
- package/dist/gate/hook-fill.js +94 -0
- package/dist/gate/hook-main.d.ts +1 -0
- package/dist/gate/hook-main.js +35 -0
- package/dist/gate/hook-process.d.ts +14 -0
- package/dist/gate/hook-process.js +64 -0
- package/dist/gate/hook.d.ts +46 -0
- package/dist/gate/hook.js +256 -0
- package/dist/gate/install.d.ts +25 -0
- package/dist/gate/install.js +126 -0
- package/dist/gate/local-bin.d.ts +8 -0
- package/dist/gate/local-bin.js +36 -0
- package/dist/gate/notes.d.ts +19 -0
- package/dist/gate/notes.js +27 -0
- package/dist/gate/novelty.d.ts +35 -0
- package/dist/gate/novelty.js +99 -0
- package/dist/gate/policy.d.ts +25 -0
- package/dist/gate/policy.js +73 -0
- package/dist/gate/record.d.ts +120 -0
- package/dist/gate/record.js +125 -0
- package/dist/gate/registry-state.d.ts +10 -0
- package/dist/gate/registry-state.js +30 -0
- package/dist/gate/registry.d.ts +56 -0
- package/dist/gate/registry.js +94 -0
- package/dist/gate/replay.d.ts +20 -0
- package/dist/gate/replay.js +257 -0
- package/dist/gate/report.d.ts +50 -0
- package/dist/gate/report.js +89 -0
- package/dist/gate/scope.d.ts +7 -0
- package/dist/gate/scope.js +27 -0
- package/dist/gate/transcripts.d.ts +68 -0
- package/dist/gate/transcripts.js +232 -0
- package/dist/hook-bin.d.ts +1 -0
- package/dist/hook-bin.js +25 -0
- package/dist/issuer.d.ts +7 -1
- package/dist/issuer.js +18 -1
- package/dist/main.d.ts +1 -0
- package/dist/main.js +64 -0
- package/dist/oidc.js +1 -1
- package/dist/sessions/actions.d.ts +35 -0
- package/dist/sessions/actions.js +351 -0
- package/dist/sessions/allow.d.ts +14 -0
- package/dist/sessions/allow.js +51 -0
- package/dist/sessions/archive.d.ts +11 -0
- package/dist/sessions/archive.js +38 -0
- package/dist/sessions/audit.d.ts +42 -0
- package/dist/sessions/audit.js +104 -0
- package/dist/sessions/baseline.d.ts +62 -0
- package/dist/sessions/baseline.js +188 -0
- package/dist/sessions/capture-runtime.d.ts +10 -0
- package/dist/sessions/capture-runtime.js +12 -0
- package/dist/sessions/cell-increments.d.ts +43 -0
- package/dist/sessions/cell-increments.js +209 -0
- package/dist/sessions/cells.d.ts +79 -0
- package/dist/sessions/cells.js +181 -0
- package/dist/sessions/codex.d.ts +53 -0
- package/dist/sessions/codex.js +679 -0
- package/dist/sessions/command.d.ts +117 -0
- package/dist/sessions/command.js +373 -0
- package/dist/sessions/copilot.d.ts +40 -0
- package/dist/sessions/copilot.js +432 -0
- package/dist/sessions/cursor.d.ts +24 -0
- package/dist/sessions/cursor.js +324 -0
- package/dist/sessions/derive.d.ts +57 -0
- package/dist/sessions/derive.js +320 -0
- package/dist/sessions/exclusion.d.ts +81 -0
- package/dist/sessions/exclusion.js +353 -0
- package/dist/sessions/extract.d.ts +179 -0
- package/dist/sessions/extract.js +1774 -0
- package/dist/sessions/fold.d.ts +17 -0
- package/dist/sessions/fold.js +71 -0
- package/dist/sessions/frame.d.ts +24 -0
- package/dist/sessions/frame.js +95 -0
- package/dist/sessions/gemini.d.ts +37 -0
- package/dist/sessions/gemini.js +613 -0
- package/dist/sessions/gold-arms.d.ts +67 -0
- package/dist/sessions/gold-arms.js +172 -0
- package/dist/sessions/gold.d.ts +197 -0
- package/dist/sessions/gold.js +463 -0
- package/dist/sessions/graph.d.ts +12 -0
- package/dist/sessions/graph.js +34 -0
- package/dist/sessions/grok.d.ts +36 -0
- package/dist/sessions/grok.js +421 -0
- package/dist/sessions/hook-calls.d.ts +3 -0
- package/dist/sessions/hook-calls.js +47 -0
- package/dist/sessions/hook-index.d.ts +53 -0
- package/dist/sessions/hook-index.js +157 -0
- package/dist/sessions/hooks.d.ts +13 -0
- package/dist/sessions/hooks.js +170 -0
- package/dist/sessions/identity.d.ts +22 -0
- package/dist/sessions/identity.js +101 -0
- package/dist/sessions/install.d.ts +54 -0
- package/dist/sessions/install.js +201 -0
- package/dist/sessions/lineage.d.ts +19 -0
- package/dist/sessions/lineage.js +118 -0
- package/dist/sessions/mcp.d.ts +6 -0
- package/dist/sessions/mcp.js +153 -0
- package/dist/sessions/merkle.d.ts +7 -0
- package/dist/sessions/merkle.js +28 -0
- package/dist/sessions/motif.d.ts +43 -0
- package/dist/sessions/motif.js +59 -0
- package/dist/sessions/package-resolve.d.ts +30 -0
- package/dist/sessions/package-resolve.js +183 -0
- package/dist/sessions/pending.d.ts +34 -0
- package/dist/sessions/pending.js +142 -0
- package/dist/sessions/pool-command.d.ts +44 -0
- package/dist/sessions/pool-command.js +542 -0
- package/dist/sessions/pool-delta.d.ts +54 -0
- package/dist/sessions/pool-delta.js +190 -0
- package/dist/sessions/pool-outbox.d.ts +63 -0
- package/dist/sessions/pool-outbox.js +260 -0
- package/dist/sessions/pool-print.d.ts +26 -0
- package/dist/sessions/pool-print.js +68 -0
- package/dist/sessions/pool-wire.d.ts +225 -0
- package/dist/sessions/pool-wire.js +620 -0
- package/dist/sessions/pool.d.ts +32 -0
- package/dist/sessions/pool.js +164 -0
- package/dist/sessions/projection.d.ts +75 -0
- package/dist/sessions/projection.js +165 -0
- package/dist/sessions/public-vocabulary.d.ts +73 -0
- package/dist/sessions/public-vocabulary.js +302 -0
- package/dist/sessions/query.d.ts +111 -0
- package/dist/sessions/query.js +246 -0
- package/dist/sessions/read-adoption.d.ts +2 -0
- package/dist/sessions/read-adoption.js +27 -0
- package/dist/sessions/read-call-shape.d.ts +2 -0
- package/dist/sessions/read-call-shape.js +25 -0
- package/dist/sessions/read-change.d.ts +2 -0
- package/dist/sessions/read-change.js +30 -0
- package/dist/sessions/read-cost.d.ts +4 -0
- package/dist/sessions/read-cost.js +37 -0
- package/dist/sessions/read-day-move.d.ts +3 -0
- package/dist/sessions/read-day-move.js +65 -0
- package/dist/sessions/read-fit.d.ts +7 -0
- package/dist/sessions/read-fit.js +41 -0
- package/dist/sessions/read-interaction.d.ts +5 -0
- package/dist/sessions/read-interaction.js +62 -0
- package/dist/sessions/read-lead-lag.d.ts +15 -0
- package/dist/sessions/read-lead-lag.js +81 -0
- package/dist/sessions/read-live.d.ts +6 -0
- package/dist/sessions/read-live.js +38 -0
- package/dist/sessions/read-method.d.ts +2 -0
- package/dist/sessions/read-method.js +29 -0
- package/dist/sessions/read-next-step.d.ts +2 -0
- package/dist/sessions/read-next-step.js +35 -0
- package/dist/sessions/read-order.d.ts +54 -0
- package/dist/sessions/read-order.js +62 -0
- package/dist/sessions/read-readable.d.ts +8 -0
- package/dist/sessions/read-readable.js +24 -0
- package/dist/sessions/read-recovery.d.ts +20 -0
- package/dist/sessions/read-recovery.js +97 -0
- package/dist/sessions/read-rows.d.ts +13 -0
- package/dist/sessions/read-rows.js +47 -0
- package/dist/sessions/read-substitution.d.ts +5 -0
- package/dist/sessions/read-substitution.js +51 -0
- package/dist/sessions/read-support.d.ts +48 -0
- package/dist/sessions/read-support.js +73 -0
- package/dist/sessions/read-tool-day.d.ts +2 -0
- package/dist/sessions/read-tool-day.js +26 -0
- package/dist/sessions/read-version-boundary.d.ts +31 -0
- package/dist/sessions/read-version-boundary.js +95 -0
- package/dist/sessions/read.d.ts +79 -0
- package/dist/sessions/read.js +84 -0
- package/dist/sessions/recall-consequence.d.ts +45 -0
- package/dist/sessions/recall-consequence.js +179 -0
- package/dist/sessions/recall-object.d.ts +137 -0
- package/dist/sessions/recall-object.js +77 -0
- package/dist/sessions/recall-pool.d.ts +19 -0
- package/dist/sessions/recall-pool.js +96 -0
- package/dist/sessions/recall-push.d.ts +26 -0
- package/dist/sessions/recall-push.js +95 -0
- package/dist/sessions/recall-reads.d.ts +117 -0
- package/dist/sessions/recall-reads.js +410 -0
- package/dist/sessions/recall-render.d.ts +19 -0
- package/dist/sessions/recall-render.js +168 -0
- package/dist/sessions/redact.d.ts +6 -0
- package/dist/sessions/redact.js +54 -0
- package/dist/sessions/repo-root.d.ts +10 -0
- package/dist/sessions/repo-root.js +101 -0
- package/dist/sessions/schema.d.ts +644 -0
- package/dist/sessions/schema.js +148 -0
- package/dist/sessions/session-shape.d.ts +73 -0
- package/dist/sessions/session-shape.js +342 -0
- package/dist/sessions/sources.d.ts +50 -0
- package/dist/sessions/sources.js +159 -0
- package/dist/sessions/steps.d.ts +105 -0
- package/dist/sessions/steps.js +250 -0
- package/dist/sessions/store.d.ts +297 -0
- package/dist/sessions/store.js +1478 -0
- package/dist/sessions/strata.d.ts +26 -0
- package/dist/sessions/strata.js +77 -0
- package/dist/sessions/survival.d.ts +25 -0
- package/dist/sessions/survival.js +230 -0
- package/dist/sessions/verify.d.ts +53 -0
- package/dist/sessions/verify.js +286 -0
- package/dist/sessions/vocab-load.d.ts +26 -0
- package/dist/sessions/vocab-load.js +61 -0
- package/dist/sessions/vscode-copilot.d.ts +38 -0
- package/dist/sessions/vscode-copilot.js +477 -0
- package/package.json +4 -5
- package/vocab/crates.json +1 -0
- package/vocab/go.json +1 -0
- package/vocab/harness.json +1 -0
- package/vocab/homebrew.json +1 -0
- package/vocab/manifest.json +1 -0
- package/vocab/maven.json +1 -0
- package/vocab/models.json +1 -0
- package/vocab/npm.json +1 -0
- package/vocab/programs.json +1 -0
- package/vocab/pypi.json +1 -0
- package/vocab/rubygems.json +1 -0
- package/dist/commands/deploy-input.d.ts +0 -38
- package/dist/commands/deploy-input.js +0 -150
- package/dist/commands/deploy.d.ts +0 -22
- package/dist/commands/deploy.js +0 -124
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -18
- package/dist/commands/templates.d.ts +0 -11
- package/dist/commands/templates.js +0 -32
- package/dist/commands/test-charge.d.ts +0 -11
- package/dist/commands/test-charge.js +0 -34
- package/dist/journal.d.ts +0 -26
- package/dist/journal.js +0 -58
- package/parity/deploy-plans/paid-api.json +0 -34
- package/parity/deploy-plans/per-task-agent.json +0 -34
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { CHANGES_FOLDER, shellCommands } from '../sessions/extract.js';
|
|
2
|
+
export const CROSSING_CLASSES = ['npm-package', 'python-package', 'mcp-server', 'skill'];
|
|
3
|
+
export const CROSSING_FORMS = ['install', 'url-install', 'fetch-and-run', 'url-fetch-and-run', 'local-bin-run', 'mcp-call', 'skill-call'];
|
|
4
|
+
/** A command that looks like a crossing and states no name to record. Counted, so a report can say what it could not read. */
|
|
5
|
+
export const UNREAD_SHAPES = ['manifest-install', 'local-path', 'variable-name', 'masked-name', 'pipe-to-shell', 'behind-xargs', 'harness-registration', 'unreadable-name'];
|
|
6
|
+
const NPM_VERBS = [
|
|
7
|
+
[['install'], 'install'], [['i'], 'install'], [['in'], 'install'], [['ins'], 'install'], [['add'], 'install'],
|
|
8
|
+
[['ci'], 'manifest'], [['clean-install'], 'manifest'],
|
|
9
|
+
[['exec'], 'run'], [['x'], 'run'],
|
|
10
|
+
[['init'], 'create'], [['create'], 'create'],
|
|
11
|
+
[['uninstall'], 'remove'], [['un'], 'remove'], [['remove'], 'remove'], [['rm'], 'remove'], [['r'], 'remove'],
|
|
12
|
+
];
|
|
13
|
+
const PNPM_VERBS = [
|
|
14
|
+
[['install'], 'install'], [['i'], 'install'], [['add'], 'install'],
|
|
15
|
+
[['dlx'], 'run'], [['create'], 'create'],
|
|
16
|
+
[['remove'], 'remove'], [['rm'], 'remove'], [['uninstall'], 'remove'], [['un'], 'remove'],
|
|
17
|
+
];
|
|
18
|
+
const YARN_VERBS = [
|
|
19
|
+
[['install'], 'install'], [['add'], 'install'], [['global', 'add'], 'install'],
|
|
20
|
+
[['dlx'], 'run'], [['create'], 'create'],
|
|
21
|
+
[['remove'], 'remove'], [['global', 'remove'], 'remove'],
|
|
22
|
+
];
|
|
23
|
+
const BUN_VERBS = [
|
|
24
|
+
[['install'], 'install'], [['i'], 'install'], [['add'], 'install'], [['a'], 'install'],
|
|
25
|
+
[['x'], 'run'], [['create'], 'create'],
|
|
26
|
+
[['remove'], 'remove'], [['rm'], 'remove'], [['uninstall'], 'remove'],
|
|
27
|
+
];
|
|
28
|
+
const PIP_VERBS = [[['install'], 'install'], [['uninstall'], 'remove']];
|
|
29
|
+
const UV_VERBS = [
|
|
30
|
+
[['add'], 'install'], [['pip', 'install'], 'install'], [['tool', 'install'], 'install'],
|
|
31
|
+
[['tool', 'run'], 'run'],
|
|
32
|
+
[['sync'], 'manifest'], [['pip', 'sync'], 'manifest'],
|
|
33
|
+
[['remove'], 'remove'], [['pip', 'uninstall'], 'remove'], [['tool', 'uninstall'], 'remove'],
|
|
34
|
+
];
|
|
35
|
+
const PIPX_VERBS = [[['install'], 'install'], [['inject'], 'install'], [['run'], 'run'], [['uninstall'], 'remove']];
|
|
36
|
+
const PROJECT_VERBS = [[['add'], 'install'], [['install'], 'manifest'], [['sync'], 'manifest'], [['remove'], 'remove']];
|
|
37
|
+
const PIPENV_VERBS = [[['install'], 'install'], [['sync'], 'manifest'], [['uninstall'], 'remove']];
|
|
38
|
+
const CONDA_VERBS = [[['install'], 'install'], [['remove'], 'remove'], [['uninstall'], 'remove']];
|
|
39
|
+
/** The runners take the name straight after the program. */
|
|
40
|
+
const RUNNER_VERBS = [[[], 'run']];
|
|
41
|
+
const TOOLS = new Map([
|
|
42
|
+
['npm', { family: 'npm-package', verbs: NPM_VERBS }],
|
|
43
|
+
['pnpm', { family: 'npm-package', verbs: PNPM_VERBS }],
|
|
44
|
+
['yarn', { family: 'npm-package', verbs: YARN_VERBS }],
|
|
45
|
+
['bun', { family: 'npm-package', verbs: BUN_VERBS }],
|
|
46
|
+
['npx', { family: 'npm-package', verbs: RUNNER_VERBS }],
|
|
47
|
+
['pnpx', { family: 'npm-package', verbs: RUNNER_VERBS }],
|
|
48
|
+
['bunx', { family: 'npm-package', verbs: RUNNER_VERBS }],
|
|
49
|
+
['pip', { family: 'python-package', verbs: PIP_VERBS }],
|
|
50
|
+
['uv', { family: 'python-package', verbs: UV_VERBS }],
|
|
51
|
+
['uvx', { family: 'python-package', verbs: RUNNER_VERBS }],
|
|
52
|
+
['pipx', { family: 'python-package', verbs: PIPX_VERBS }],
|
|
53
|
+
['poetry', { family: 'python-package', verbs: PROJECT_VERBS }],
|
|
54
|
+
['pdm', { family: 'python-package', verbs: PROJECT_VERBS }],
|
|
55
|
+
['rye', { family: 'python-package', verbs: PROJECT_VERBS }],
|
|
56
|
+
['pipenv', { family: 'python-package', verbs: PIPENV_VERBS }],
|
|
57
|
+
['conda', { family: 'python-package', verbs: CONDA_VERBS }],
|
|
58
|
+
['mamba', { family: 'python-package', verbs: CONDA_VERBS }],
|
|
59
|
+
['micromamba', { family: 'python-package', verbs: CONDA_VERBS }],
|
|
60
|
+
]);
|
|
61
|
+
/** A flag here owns the next word, which is therefore not a package name. */
|
|
62
|
+
const VALUE_FLAGS = {
|
|
63
|
+
'npm-package': new Set(['-w', '--workspace', '--registry', '--prefix', '--filter', '-F', '-C', '--dir', '--cache', '--tag', '--cwd', '--loglevel', '--userconfig', '--globalconfig', '-p', '--package', '-c', '--call', '--omit', '--include', '--save-prefix', '--cafile', '--cert', '--key', '--nodedir', '--store-dir', '--modules-dir', '--virtual-store-dir', '--lockfile-dir', '--global-dir', '--cache-dir', '--cache-folder', '--modules-folder', '--global-folder', '--link-folder', '--script-shell', '--tmp']),
|
|
64
|
+
'python-package': new Set(['-r', '--requirement', '-e', '--editable', '-c', '--constraint', '-i', '--index-url', '--extra-index-url', '--index', '--default-index', '-t', '--target', '-f', '--find-links', '--python', '-p', '--from', '--with', '--with-editable', '--with-requirements', '--prefix', '--root', '--group', '--extra', '--only-binary', '--no-binary', '--trusted-host', '--proxy', '--cache-dir', '--timeout', '--retries', '--upgrade-strategy', '--log', '--file', '--suffix', '--pip-args', '--spec', '--channel', '-n', '--name', '-G']),
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Flags that own the next word for the family but are a switch for one program: pnpm's `-w` is `--workspace-root`, `-n` is `--no-interaction`
|
|
68
|
+
* for poetry and `--no-cache` for uv and uvx, and `-f` is `--force` for pipx.
|
|
69
|
+
*/
|
|
70
|
+
const FLAG_WITHOUT_VALUE = new Set(['pnpm -w', 'poetry -n', 'uv -n', 'uvx -n', 'pipx -f']);
|
|
71
|
+
/** These tell a runner to use only what is already local, so nothing crosses. */
|
|
72
|
+
const NO_FETCH_FLAGS = new Set(['--no-install', '--no', '--offline']);
|
|
73
|
+
const LOCAL_SPEC = /^(?:\.{1,2}$|\.{0,2}[\\/]|~|[A-Za-z]:[\\/]|file:|link:|workspace:|portal:)/;
|
|
74
|
+
const ARCHIVE = /\.(?:whl|tgz|zip|tar\.gz)$/;
|
|
75
|
+
const HOSTED = /^(?:github|gitlab|bitbucket|gist):/;
|
|
76
|
+
const SCP_URL = /^[^@/\s]+@[^:/\s]+:/;
|
|
77
|
+
const VARIABLE = /\$|^%.*%$/;
|
|
78
|
+
/** The redactor took the whole word. A mask is not a name, and novelty must not count one. */
|
|
79
|
+
const MASKED = /^(?:<(?:secret|email|org-domain)>)+$/;
|
|
80
|
+
const namesNothing = (text) => {
|
|
81
|
+
if (VARIABLE.test(text))
|
|
82
|
+
return { kind: 'unread', shape: 'variable-name' };
|
|
83
|
+
return MASKED.test(text) ? { kind: 'unread', shape: 'masked-name' } : null;
|
|
84
|
+
};
|
|
85
|
+
const versionFormOf = (version) => {
|
|
86
|
+
if (version === null)
|
|
87
|
+
return 'unstated';
|
|
88
|
+
if (/^v?\d+\.\d+\.\d+(?:\.\d+)*(?:[-+.][0-9A-Za-z.+-]*)?$/.test(version))
|
|
89
|
+
return 'pinned';
|
|
90
|
+
if (/^[\^~<>=!*]/.test(version) || /(?:^|\.)(?:x|\*)(?:\.|$)/.test(version) || /[,|\s]/.test(version))
|
|
91
|
+
return 'range';
|
|
92
|
+
return 'tag';
|
|
93
|
+
};
|
|
94
|
+
const GIT_URL = /^(?:git\+|ssh:\/\/|git:\/\/)/i;
|
|
95
|
+
/** The first `@` after a URL's host that does not directly follow a `/`, which would be a scope; -1 when there is none. */
|
|
96
|
+
const firstRefAt = (stem) => {
|
|
97
|
+
const from = stem.indexOf('/', stem.indexOf('://') + 3);
|
|
98
|
+
if (from < 0)
|
|
99
|
+
return -1;
|
|
100
|
+
const ref = /(?<!\/)@/g;
|
|
101
|
+
ref.lastIndex = from;
|
|
102
|
+
return ref.exec(stem)?.index ?? -1;
|
|
103
|
+
};
|
|
104
|
+
/** A query can hold a signed token no pattern knows, and a ref after `#` is the version; neither belongs in a name. */
|
|
105
|
+
const urlSpec = (text) => {
|
|
106
|
+
const bare = text.replace(/^git\+/, '');
|
|
107
|
+
const hash = bare.indexOf('#');
|
|
108
|
+
const end = hash < 0 ? bare : bare.slice(0, hash);
|
|
109
|
+
const ref = hash < 0 ? null : bare.slice(hash + 1);
|
|
110
|
+
const query = end.indexOf('?');
|
|
111
|
+
const stem = query < 0 ? end : end.slice(0, query);
|
|
112
|
+
// A git URL's `@ref` follows the path and may hold a slash (`@feature/x`), so it starts at the first `@` past the host that is not a
|
|
113
|
+
// scope's (`/@scope`). Any other URL has a ref only after its last slash. An `@` before the path is a credential, never a ref: a word with no path has no ref,
|
|
114
|
+
// and `withoutCredentials` strips its user later.
|
|
115
|
+
const git = GIT_URL.test(text);
|
|
116
|
+
const at = git ? firstRefAt(stem) : stem.lastIndexOf('@');
|
|
117
|
+
const slash = stem.lastIndexOf('/');
|
|
118
|
+
const hasRef = git ? at >= 0 : at > slash && slash > stem.indexOf('://') + 2;
|
|
119
|
+
const name = hasRef ? stem.slice(0, at) : stem;
|
|
120
|
+
const stated = hasRef ? stem.slice(at + 1) : ref === null || ref.startsWith('egg=') ? null : ref;
|
|
121
|
+
return { kind: 'named', name, version: stated, versionForm: 'url', url: true };
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* A `file:` spec holds a path on this machine, which has slashes and `://` like a URL: it is read as a folder before any URL test, so no
|
|
125
|
+
* path becomes a name. A name may come before it (`foo@file:...`, `foo @ file:...`), and `link:`, `workspace:`, and `portal:` are folders too.
|
|
126
|
+
*/
|
|
127
|
+
const FILE_SPEC = /^(?:(?:@[^@/\s]+\/)?[^@/\s]+\s*@\s*)?(?:git\+)?(?:file|link|workspace|portal):/i;
|
|
128
|
+
/** pip's PEP 508 direct reference, `name @ url` with spaces: the name before it is only a label, so the URL is what is read. */
|
|
129
|
+
const withoutLabel = (text) => text.replace(/^(?:@[^@/\s]+\/)?[^@/\s]+(?:\s+@\s*|\s*@\s+|@(?=git\+))(?=\S)/, '');
|
|
130
|
+
const isUrl = (text) => text.includes('://') || text.startsWith('git+') || HOSTED.test(text) || SCP_URL.test(text);
|
|
131
|
+
const atVersion = (text) => {
|
|
132
|
+
const at = text.lastIndexOf('@');
|
|
133
|
+
if (at <= 0)
|
|
134
|
+
return { name: text, version: null };
|
|
135
|
+
return { name: text.slice(0, at), version: text.slice(at + 1) === '' ? null : text.slice(at + 1) };
|
|
136
|
+
};
|
|
137
|
+
const nodeSpec = (whole) => {
|
|
138
|
+
const nothing = namesNothing(whole);
|
|
139
|
+
if (nothing !== null)
|
|
140
|
+
return nothing;
|
|
141
|
+
const text = withoutLabel(whole);
|
|
142
|
+
// `alias@npm:real@1` installs `real`; the alias is only what the code imports it as. It reads like an scp URL, so it goes first.
|
|
143
|
+
// A leading `npm:` is the same protocol with no alias.
|
|
144
|
+
const alias = /^(?:@?[^@]+@)?npm:(.+)$/.exec(text)?.[1];
|
|
145
|
+
if (alias !== undefined)
|
|
146
|
+
return nodeSpec(alias);
|
|
147
|
+
// pnpm's leading `jsr:@scope/name[@version]` is the JSR package itself.
|
|
148
|
+
const jsr = /^jsr:(.+)$/.exec(text)?.[1];
|
|
149
|
+
if (jsr !== undefined)
|
|
150
|
+
return nodeSpec(jsr);
|
|
151
|
+
// pnpm's `catalog:` takes its version from the workspace, and `jsr:` names a version or a JSR package; neither is a path or an scp URL.
|
|
152
|
+
const protocol = /^(@?[^@]+)@(catalog|jsr):(.*)$/.exec(text);
|
|
153
|
+
if (protocol !== null) {
|
|
154
|
+
const [, label = '', kind, rest = ''] = protocol;
|
|
155
|
+
if (kind === 'jsr' && (rest.startsWith('@') || rest.includes('/')))
|
|
156
|
+
return nodeSpec(rest);
|
|
157
|
+
const version = kind === 'jsr' && rest !== '' ? rest : null;
|
|
158
|
+
return { kind: 'named', name: label, version, versionForm: versionFormOf(version), url: false };
|
|
159
|
+
}
|
|
160
|
+
if (FILE_SPEC.test(text))
|
|
161
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
162
|
+
if (isUrl(text))
|
|
163
|
+
return urlSpec(text);
|
|
164
|
+
if (LOCAL_SPEC.test(text) || ARCHIVE.test(text))
|
|
165
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
166
|
+
// One slash with no leading `@` is a GitHub shorthand; more than one is a folder.
|
|
167
|
+
const parts = (text.split('#')[0] ?? text).split('/').length;
|
|
168
|
+
if (parts > 2 || text.includes('\\'))
|
|
169
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
170
|
+
if (parts === 2 && !text.startsWith('@'))
|
|
171
|
+
return urlSpec(text);
|
|
172
|
+
const { name, version } = atVersion(text);
|
|
173
|
+
return { kind: 'named', name, version, versionForm: versionFormOf(version), url: false };
|
|
174
|
+
};
|
|
175
|
+
const PYTHON_SPEC = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(===?|~=|!=|>=|<=|<|>)?\s*(.*)$/;
|
|
176
|
+
const pythonSpec = (whole) => {
|
|
177
|
+
const nothing = namesNothing(whole);
|
|
178
|
+
if (nothing !== null)
|
|
179
|
+
return nothing;
|
|
180
|
+
const text = withoutLabel(whole);
|
|
181
|
+
if (FILE_SPEC.test(text))
|
|
182
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
183
|
+
if (isUrl(text))
|
|
184
|
+
return urlSpec(text);
|
|
185
|
+
if (LOCAL_SPEC.test(text) || ARCHIVE.test(text) || /[\\/]/.test(text))
|
|
186
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
187
|
+
// uv writes `name@version`, where the `@` is not a PEP 508 direct reference (a URL, handled above).
|
|
188
|
+
const { name: head, version: atStated } = atVersion(text);
|
|
189
|
+
// An environment marker (`;python_version<3`) says where the requirement applies, not which one it is.
|
|
190
|
+
const match = PYTHON_SPEC.exec((head.split(';')[0] ?? head).trim());
|
|
191
|
+
if (match === null)
|
|
192
|
+
return { kind: 'unread', shape: 'local-path' };
|
|
193
|
+
const [, raw = '', operator = '', stated = ''] = match;
|
|
194
|
+
// Text after the name that no operator introduces is outside PEP 508: refused, never kept as a version.
|
|
195
|
+
if (operator === '' && stated !== '')
|
|
196
|
+
return { kind: 'unread', shape: 'unreadable-name' };
|
|
197
|
+
// The same holds after the `@`: a word outside PEP 508 there would be kept as a version.
|
|
198
|
+
if (atStated !== null && /[\s#;]/.test(atStated))
|
|
199
|
+
return { kind: 'unread', shape: 'unreadable-name' };
|
|
200
|
+
// PEP 503: these spellings are one project, so novelty must not count them twice.
|
|
201
|
+
const name = raw.toLowerCase().replace(/[-_.]+/g, '-');
|
|
202
|
+
if (atStated !== null)
|
|
203
|
+
return { kind: 'named', name, version: atStated, versionForm: versionFormOf(atStated), url: false };
|
|
204
|
+
if (stated === '')
|
|
205
|
+
return { kind: 'named', name, version: null, versionForm: 'unstated', url: false };
|
|
206
|
+
const exact = operator === '==' || operator === '===';
|
|
207
|
+
return { kind: 'named', name, version: exact ? stated : `${operator}${stated}`, versionForm: exact ? 'pinned' : 'range', url: false };
|
|
208
|
+
};
|
|
209
|
+
const specOf = (family, text) => (family === 'npm-package' ? nodeSpec(text) : pythonSpec(text));
|
|
210
|
+
/** `npm init foo` runs `create-foo`, `npm init @scope/foo` runs `@scope/create-foo`, and `npm init @scope` runs `@scope/create`. */
|
|
211
|
+
const creatorSpec = (text) => {
|
|
212
|
+
const { name, version } = atVersion(text);
|
|
213
|
+
const suffix = version === null ? '' : `@${version}`;
|
|
214
|
+
if (!name.startsWith('@'))
|
|
215
|
+
return `create-${name}${suffix}`;
|
|
216
|
+
const [scope = '', rest] = name.split('/');
|
|
217
|
+
return `${scope}/${rest === undefined ? 'create' : `create-${rest}`}${suffix}`;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* `limit` is how many names a runner's own words take. Once that many are read, every later word is a name, whatever it looks like: it
|
|
221
|
+
* belongs to the fetched program, which may take `-p` or `--package` for its own. Flags before the limit are the runner's.
|
|
222
|
+
*/
|
|
223
|
+
export const readWords = (command, family, limit = Number.POSITIVE_INFINITY) => {
|
|
224
|
+
const names = [];
|
|
225
|
+
const flags = new Map();
|
|
226
|
+
const bare = new Set();
|
|
227
|
+
const put = (flag, value) => {
|
|
228
|
+
flags.set(flag, [...(flags.get(flag) ?? []), value]);
|
|
229
|
+
};
|
|
230
|
+
for (let at = 0; at < command.args.length; at += 1) {
|
|
231
|
+
const { text, quoted } = command.args[at] ?? { text: '', quoted: false };
|
|
232
|
+
if (quoted || !text.startsWith('-') || names.length >= limit)
|
|
233
|
+
names.push(text);
|
|
234
|
+
else if (text.includes('='))
|
|
235
|
+
put(text.slice(0, text.indexOf('=')), text.slice(text.indexOf('=') + 1));
|
|
236
|
+
else if (VALUE_FLAGS[family].has(text) && !FLAG_WITHOUT_VALUE.has(`${command.program} ${text}`)) {
|
|
237
|
+
put(text, command.args[at + 1]?.text ?? '');
|
|
238
|
+
at += 1;
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
bare.add(text);
|
|
242
|
+
}
|
|
243
|
+
return { names, flags, bare };
|
|
244
|
+
};
|
|
245
|
+
const verbOf = (verbs, names) => {
|
|
246
|
+
let best = null;
|
|
247
|
+
for (const [path, action] of verbs) {
|
|
248
|
+
if ((best === null || path.length > best.length) && path.every((word, at) => names[at] === word))
|
|
249
|
+
best = { action, rest: names.slice(path.length), length: path.length, path };
|
|
250
|
+
}
|
|
251
|
+
return best;
|
|
252
|
+
};
|
|
253
|
+
const allFlags = (words, ...names) => names.flatMap((name) => words.flags.get(name) ?? []);
|
|
254
|
+
const PIP_NAME = /^pip[\d.]*$/;
|
|
255
|
+
const PYTHON_NAME = /^(?:python[\d.]*|py)$/;
|
|
256
|
+
const MODULE_NAME = /^(?:pip[\d.]*|pipx|uv)$/;
|
|
257
|
+
const MODULE_PROGRAMS = { pip: 'python -m pip', pipx: 'python -m pipx', uv: 'python -m uv' };
|
|
258
|
+
/** `python -m pip ...` is `pip ...`, and `-m pipx` and `-m uv` the same. The words before `-m` are the interpreter's own flags. */
|
|
259
|
+
const moduleCommand = (command) => {
|
|
260
|
+
if (!PYTHON_NAME.test(command.program))
|
|
261
|
+
return null;
|
|
262
|
+
const at = command.args.findIndex((word) => word.text === '-m');
|
|
263
|
+
const name = command.args[at + 1]?.text ?? '';
|
|
264
|
+
if (at < 0 || !MODULE_NAME.test(name))
|
|
265
|
+
return null;
|
|
266
|
+
const module = name === 'pipx' || name === 'uv' ? name : 'pip';
|
|
267
|
+
return { module, command: { ...command, program: module, args: command.args.slice(at + 2) } };
|
|
268
|
+
};
|
|
269
|
+
const PIP_TOOL = { family: 'python-package', verbs: PIP_VERBS };
|
|
270
|
+
/** The listed program a word names, with its table row. `pip3.12` is `pip`. */
|
|
271
|
+
const toolOf = (program) => {
|
|
272
|
+
const named = PIP_NAME.test(program) ? 'pip' : program;
|
|
273
|
+
for (const [key, tool] of TOOLS)
|
|
274
|
+
if (key === named)
|
|
275
|
+
return { program: key, tool };
|
|
276
|
+
return undefined;
|
|
277
|
+
};
|
|
278
|
+
const SHELL_SINK = /^(?:sh|bash|zsh|dash|python[\d.]*|node|iex|invoke-expression|pwsh|powershell)$/;
|
|
279
|
+
const FETCHER = /^(?:curl|wget|irm|iwr|invoke-restmethod|invoke-webrequest)$/;
|
|
280
|
+
const emptyReading = () => ({ crossings: [], unread: [], removals: [] });
|
|
281
|
+
const crossingOf = (family, spec, action, program, statusReports) => {
|
|
282
|
+
const form = action === 'run' ? (spec.url ? 'url-fetch-and-run' : 'fetch-and-run') : spec.url ? 'url-install' : 'install';
|
|
283
|
+
return { class: family, form, name: spec.name, version: spec.version, versionForm: spec.versionForm, program, statusReports, registryAsked: true };
|
|
284
|
+
};
|
|
285
|
+
const addSpecs = (reading, family, texts, action, program, statusReports) => {
|
|
286
|
+
for (const text of texts) {
|
|
287
|
+
const spec = specOf(family, text);
|
|
288
|
+
if (spec.kind === 'unread')
|
|
289
|
+
reading.unread.push(spec.shape);
|
|
290
|
+
else
|
|
291
|
+
reading.crossings.push(crossingOf(family, spec, action, program, statusReports));
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
/** `npx skills add` flags that take a value, so the word after one is not the source. */
|
|
295
|
+
const SKILL_VALUE_FLAGS = new Set(['-s', '--skill', '-a', '--agent']);
|
|
296
|
+
/** The first word after `add` that is neither a flag nor a flag's value. */
|
|
297
|
+
const skillSource = (words) => {
|
|
298
|
+
for (let at = 0; at < words.length; at += 1) {
|
|
299
|
+
const word = words[at] ?? '';
|
|
300
|
+
if (SKILL_VALUE_FLAGS.has(word))
|
|
301
|
+
at += 1;
|
|
302
|
+
else if (!word.startsWith('-'))
|
|
303
|
+
return word;
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
};
|
|
307
|
+
const readRun = (reading, family, words, rest, command) => {
|
|
308
|
+
if ([...NO_FETCH_FLAGS].some((flag) => words.bare.has(flag)))
|
|
309
|
+
return;
|
|
310
|
+
const [first, sub] = rest;
|
|
311
|
+
const source = sub === 'add' ? skillSource(rest.slice(2)) : undefined;
|
|
312
|
+
if (first !== undefined && atVersion(first).name === 'skills' && source !== undefined) {
|
|
313
|
+
if (FILE_SPEC.test(source)) {
|
|
314
|
+
reading.unread.push('local-path');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// `owner/repo@skill` picks one skill of a source: the source is what crosses.
|
|
318
|
+
const picked = /^([^@:\s]+\/[^@:\s]+)@[^@/:\s]+$/.exec(source)?.[1] ?? source;
|
|
319
|
+
const spec = namesNothing(picked) ?? (isUrl(picked) ? urlSpec(picked) : null);
|
|
320
|
+
if (spec?.kind === 'unread')
|
|
321
|
+
reading.unread.push(spec.shape);
|
|
322
|
+
else if (!LOCAL_SPEC.test(picked))
|
|
323
|
+
reading.crossings.push({ class: 'skill', form: 'install', name: spec?.name ?? picked, version: null, versionForm: 'unstated', program: command.program, statusReports: command.read, registryAsked: true });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const packaged = family === 'npm-package' ? allFlags(words, '-p', '--package') : allFlags(words, '--from', '--spec');
|
|
327
|
+
if (packaged.length > 0)
|
|
328
|
+
addSpecs(reading, family, packaged, 'run', command.program, command.read);
|
|
329
|
+
else if (first !== undefined)
|
|
330
|
+
addSpecs(reading, family, [first], 'run', command.program, command.read);
|
|
331
|
+
};
|
|
332
|
+
const readRemoval = (reading, family, names) => {
|
|
333
|
+
for (const text of names) {
|
|
334
|
+
const spec = specOf(family, text);
|
|
335
|
+
if (spec.kind === 'named')
|
|
336
|
+
reading.removals.push({ class: family, name: spec.name });
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const readInstall = (reading, family, words, rest, command) => {
|
|
340
|
+
const editable = allFlags(words, '-e', '--editable');
|
|
341
|
+
const texts = [...editable, ...rest];
|
|
342
|
+
if (texts.length === 0 || allFlags(words, '-r', '--requirement', '--file').length > 0)
|
|
343
|
+
reading.unread.push('manifest-install');
|
|
344
|
+
addSpecs(reading, family, texts, 'install', command.program, command.read);
|
|
345
|
+
};
|
|
346
|
+
/** A creator is a plain name that gets `create-` put before it; a path, a URL, or `org/repo` is no creator and is not prefixed into a name. */
|
|
347
|
+
const readCreator = (reading, family, text, command) => {
|
|
348
|
+
const spec = nodeSpec(text);
|
|
349
|
+
if (spec.kind === 'unread')
|
|
350
|
+
reading.unread.push(spec.shape);
|
|
351
|
+
else if (spec.url)
|
|
352
|
+
reading.unread.push('unreadable-name');
|
|
353
|
+
else
|
|
354
|
+
addSpecs(reading, family, [creatorSpec(text)], 'run', command.program, command.read);
|
|
355
|
+
};
|
|
356
|
+
const readTool = (reading, tool, command) => {
|
|
357
|
+
const { family } = tool;
|
|
358
|
+
let words = readWords(command, family);
|
|
359
|
+
// A bare `yarn` is its install; `yarn foo` runs a script.
|
|
360
|
+
if (command.program === 'yarn' && words.names.length === 0) {
|
|
361
|
+
reading.unread.push('manifest-install');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// `uv run --with a` installs into a throwaway environment and runs: a fetch-and-run, whatever the script is.
|
|
365
|
+
if (command.program === 'uv' && words.names[0] === 'run') {
|
|
366
|
+
// `uv run pip install x` is the same pick as `pip install x`: read it as that. Any other program after `run` stays unread.
|
|
367
|
+
// It stays recorded under `uv`: uv's pip runs in the project venv and reads that venv's `pip.conf`, so the registry question must refuse it.
|
|
368
|
+
addSpecs(reading, family, allFlags(words, '--with'), 'run', command.program, command.read);
|
|
369
|
+
// A flag may sit between the two (`pip -q install x`).
|
|
370
|
+
const pip = command.args.findIndex((word, at) => word.text === 'pip' && readWords({ ...command, args: command.args.slice(at + 1) }, family).names[0] === 'install');
|
|
371
|
+
if (words.names[1] === 'pip' && words.names[2] === 'install' && pip >= 0)
|
|
372
|
+
readTool(reading, PIP_TOOL, { ...command, args: command.args.slice(pip + 1) });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// `yarn workspace <name> add x` is `yarn add x` run in that workspace: two words that are not the verb's.
|
|
376
|
+
const stripped = command.program === 'yarn' && words.names[0] === 'workspace' ? 2 : 0;
|
|
377
|
+
if (stripped > 0)
|
|
378
|
+
words = { ...words, names: words.names.slice(stripped) };
|
|
379
|
+
let verb = verbOf(tool.verbs, words.names);
|
|
380
|
+
if (verb === null)
|
|
381
|
+
return;
|
|
382
|
+
// Past the runner's own word, the flags are the fetched program's: read again, with the rest left as names.
|
|
383
|
+
if (verb.action === 'run') {
|
|
384
|
+
const again = readWords(command, family, stripped + verb.length + 1);
|
|
385
|
+
words = { ...again, names: again.names.slice(stripped) };
|
|
386
|
+
verb = verbOf(tool.verbs, words.names) ?? verb;
|
|
387
|
+
}
|
|
388
|
+
const toolForm = command.program === 'uvx' || (command.program === 'uv' && verb.path[0] === 'tool');
|
|
389
|
+
if (verb.action === 'manifest')
|
|
390
|
+
reading.unread.push('manifest-install');
|
|
391
|
+
else if (verb.action === 'remove')
|
|
392
|
+
readRemoval(reading, family, verb.rest);
|
|
393
|
+
else if (verb.action === 'install')
|
|
394
|
+
readInstall(reading, family, words, verb.path[0] === 'inject' ? verb.rest.slice(1) : verb.rest, command);
|
|
395
|
+
else if (verb.action === 'run')
|
|
396
|
+
readRun(reading, family, words, verb.rest, command);
|
|
397
|
+
else if (verb.rest[0] !== undefined)
|
|
398
|
+
readCreator(reading, family, verb.rest[0], command);
|
|
399
|
+
// uv's tool forms take extra packages with `--with`; they install beside the tool.
|
|
400
|
+
if (toolForm && (verb.action === 'run' || verb.action === 'install'))
|
|
401
|
+
addSpecs(reading, family, allFlags(words, '--with'), verb.action, command.program, command.read);
|
|
402
|
+
};
|
|
403
|
+
const namesOf = (command) => command.args.filter((word) => !word.text.startsWith('-')).map((word) => word.text);
|
|
404
|
+
/** Names no package, and says a crossing's shape went by: the name is behind a pipe, or the harness took it. */
|
|
405
|
+
const readShape = (reading, command, previous) => {
|
|
406
|
+
const names = namesOf(command);
|
|
407
|
+
if (previous?.link === 'pipe' && FETCHER.test(previous.program) && SHELL_SINK.test(command.program))
|
|
408
|
+
reading.unread.push('pipe-to-shell');
|
|
409
|
+
if (command.program === 'xargs') {
|
|
410
|
+
const at = names.findIndex((name) => toolOf(name) !== undefined);
|
|
411
|
+
const found = toolOf(names[at] ?? '');
|
|
412
|
+
if (found !== undefined && verbOf(found.tool.verbs, names.slice(at + 1))?.action === 'install')
|
|
413
|
+
reading.unread.push('behind-xargs');
|
|
414
|
+
}
|
|
415
|
+
if (command.program === 'claude' || command.program === 'codex') {
|
|
416
|
+
const [first, second] = names;
|
|
417
|
+
if ((first === 'mcp' || first === 'plugin') && (second === 'add' || second === 'install'))
|
|
418
|
+
reading.unread.push('harness-registration');
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const readCommand = (reading, command, previous) => {
|
|
422
|
+
readShape(reading, command, previous);
|
|
423
|
+
const viaModule = moduleCommand(command);
|
|
424
|
+
const found = viaModule === null ? toolOf(command.program) : toolOf(viaModule.module);
|
|
425
|
+
if (found === undefined)
|
|
426
|
+
return;
|
|
427
|
+
const direct = { ...(viaModule?.command ?? command), program: found.program };
|
|
428
|
+
const from = reading.crossings.length;
|
|
429
|
+
readTool(reading, found.tool, direct);
|
|
430
|
+
// `python -m pip` is how a call goes around a stopped `pip`, so a record has to keep the two apart.
|
|
431
|
+
if (viaModule !== null)
|
|
432
|
+
for (const crossing of reading.crossings.slice(from))
|
|
433
|
+
crossing.program = MODULE_PROGRAMS[viaModule.module];
|
|
434
|
+
};
|
|
435
|
+
const stringField = (input, key) => {
|
|
436
|
+
const value = typeof input === 'object' && input !== null ? Reflect.get(input, key) : undefined;
|
|
437
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
438
|
+
};
|
|
439
|
+
/** A flag that points a manager at another registry, config, or folder, so its config is not the one the gate's question would read. */
|
|
440
|
+
const REGISTRY_FLAGS = new Set(['--registry', '-i', '--index-url', '--extra-index-url', '--index', '--default-index', '--userconfig', '--globalconfig', '--prefix', '-C', '--dir', '-f', '--find-links', '--no-index']);
|
|
441
|
+
/** `--@scope:registry=url` sends one scope elsewhere. */
|
|
442
|
+
const SCOPE_REGISTRY = /^--@[^=\s]*:registry(?:=|$)/;
|
|
443
|
+
/** `--config.registry=url` (pnpm) and `--config.@scope:registry=url`, and `-ihttps://...` with the value attached. */
|
|
444
|
+
const CONFIG_REGISTRY = /^--config\.[^=\s]*registry(?:=|$)/;
|
|
445
|
+
const ATTACHED_INDEX = /^-i./;
|
|
446
|
+
/** PowerShell's own folder stack. Local to the gate: `CHANGES_FOLDER` also drives capture's read-back of the working folder. */
|
|
447
|
+
const FOLDER_STACK = new Set(['push-location', 'pop-location']);
|
|
448
|
+
/** An assignment of a manager's own variable, or of one that moves its config or changes which manager runs, in any shell's spelling. Over-matching only leaves a name unasked. */
|
|
449
|
+
const REGISTRY_ENVIRONMENT = /(?:^|[^A-Za-z0-9_])(?:(?:npm_config_|pip_|uv_)[A-Za-z0-9_]*|home|userprofile|xdg_config_home|xdg_config_dirs|appdata|path|virtual_env)\s*\+?=/i;
|
|
450
|
+
/**
|
|
451
|
+
* Other ways a call reads another config than the one the gate's question reads: `sudo` (root's), a venv's `activate` (its `pip.conf`), and
|
|
452
|
+
* a manager run by path (`.venv/bin/pip`, which reads that venv's); a sourced file (`source`, `.`, `workon`), which can set anything; an environment
|
|
453
|
+
* set without an `=` (`Set-Item Env:`, `SetEnvironmentVariable`); and `npm config set`, which writes the config the next command reads.
|
|
454
|
+
* Over-matching only leaves a name unasked.
|
|
455
|
+
*/
|
|
456
|
+
const OTHER_CONFIG = /(?:^|[\s;&|(])sudo(?:\s|$)|(?:^|[\s;&|(])(?:source|\.|workon)\s|\bset-item\s+(?:-path\s+)?env:|\bsetenvironmentvariable\b|\bnpm\s+config\s+set\b|(?:^|[\\/\s;&|("'])activate(?:\.\w+)?(?=["'\s;&|)]|$)|[\\/](?:npm|npx|pnpm|pnpx|pip[\d.]*|python[\d.]*|py)(?:\.cmd|\.exe|\.bat)?(?=["']?(?:\s|$))/i;
|
|
457
|
+
const choosesRegistry = (command) => command.args.some(({ text }) => REGISTRY_FLAGS.has(text.split('=')[0] ?? '') || SCOPE_REGISTRY.test(text) || CONFIG_REGISTRY.test(text) || ATTACHED_INDEX.test(text) || /(?:^|@)jsr:/.test(text));
|
|
458
|
+
const checkShell = (input, redact) => {
|
|
459
|
+
const command = stringField(input, 'command');
|
|
460
|
+
const reading = emptyReading();
|
|
461
|
+
if (command === undefined)
|
|
462
|
+
return reading;
|
|
463
|
+
const commands = shellCommands(command, redact);
|
|
464
|
+
const environment = REGISTRY_ENVIRONMENT.test(command) || OTHER_CONFIG.test(command);
|
|
465
|
+
let moved = false;
|
|
466
|
+
commands.forEach((held, at) => {
|
|
467
|
+
const from = reading.crossings.length;
|
|
468
|
+
readCommand(reading, held, commands[at - 1]);
|
|
469
|
+
if (environment || moved || choosesRegistry(held)) {
|
|
470
|
+
for (const crossing of reading.crossings.slice(from))
|
|
471
|
+
crossing.registryAsked = false;
|
|
472
|
+
}
|
|
473
|
+
moved ||= CHANGES_FOLDER.has(held.program) || FOLDER_STACK.has(held.program);
|
|
474
|
+
});
|
|
475
|
+
return reading;
|
|
476
|
+
};
|
|
477
|
+
// The grammar of a name, by family. The readers above recognize shapes; these decide what may be written, so a spelling no reader has
|
|
478
|
+
// met yet cannot put a path into `crossings.jsonl` or a question to a registry.
|
|
479
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
480
|
+
const PEP508_NAME = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/i;
|
|
481
|
+
const MCP_NAME = /^[a-z0-9_][a-z0-9._-]*$/i;
|
|
482
|
+
const SKILL_NAME = /^[a-z0-9_][a-z0-9._:-]*$/i;
|
|
483
|
+
const NAME_LENGTH = 214;
|
|
484
|
+
/** The harness and a skill's author name these in a line or two; nothing near the length of a package's limit. */
|
|
485
|
+
const LABEL_LENGTH = 128;
|
|
486
|
+
const SEGMENT = '[A-Za-z0-9._-]+';
|
|
487
|
+
/** A segment that is not `.` or `..`: those two name a folder, never an owner, a repository, or a part of a URL's path. */
|
|
488
|
+
const PLAIN = String.raw `(?!(?:\.|%2[eE]){1,2}(?:/|$))`;
|
|
489
|
+
/** An owner starts with a letter or digit, so `.secret/x` and `C:x` are not `owner/repo`. */
|
|
490
|
+
const OWNER = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
|
491
|
+
const MASK = '<(?:secret|email|org-domain)>';
|
|
492
|
+
/** A host starts with a letter, a digit, or a mask, so `..` and `-x` are not hosts. */
|
|
493
|
+
const HOST = String.raw `(?:[A-Za-z0-9]|${MASK})(?:[A-Za-z0-9.-]|${MASK})*`;
|
|
494
|
+
const URL_CHARS = String.raw `(?:[A-Za-z0-9._~%+-]|${MASK})*`;
|
|
495
|
+
/** An `@` in a path opens a segment (a scope, `/@scope`) and is followed by a name: one that ends a segment or sits inside one is a ref or a path. */
|
|
496
|
+
const URL_SEGMENT = String.raw `/${PLAIN}(?:@(?![/@]|$))?${URL_CHARS}`;
|
|
497
|
+
/**
|
|
498
|
+
* A URL keeps a scheme, a host, and a path: no credentials, query, or fragment. `ssh://` and `git://` also take npm's scp spelling,
|
|
499
|
+
* `host:owner/repo`, where an `https` URL takes only a port. No segment of the path is `.` or `..`.
|
|
500
|
+
*/
|
|
501
|
+
const URL_NAME = new RegExp(String.raw `^(?:https?://${HOST}(?::\d+)?|(?:ssh|git)://${HOST}(?::${OWNER}(?:\.[A-Za-z0-9_-]+)*)?)(?:${URL_SEGMENT})*$`, 'i');
|
|
502
|
+
/** `host:path`, with a host of at least two characters, so `C:secret/x` is a drive and never a host. */
|
|
503
|
+
const SCP_NAME = new RegExp(String.raw `^(?=[^:]{2})${HOST}:${PLAIN}${SEGMENT}(?:/${PLAIN}${SEGMENT})*$`);
|
|
504
|
+
const HOSTED_NAME = new RegExp(String.raw `^(?:github|gitlab|bitbucket|gist):${PLAIN}${SEGMENT}(?:/${PLAIN}${SEGMENT})?$`);
|
|
505
|
+
const SHORTHAND_NAME = new RegExp(String.raw `^${OWNER}/${PLAIN}${SEGMENT}$`);
|
|
506
|
+
/** A skill source is a hosted `owner/repo`, optionally into a folder, or a URL. */
|
|
507
|
+
const SKILL_SOURCE = new RegExp(String.raw `^${OWNER}(?:/${PLAIN}${SEGMENT}){1,5}$`);
|
|
508
|
+
/** A URL's ref is a tag, a branch, or a commit, and `semver:` ranges: anything else could carry a path or a parameter. */
|
|
509
|
+
const PLAIN_REF = /^(?:semver:)?[\w.^~*+-]+$/;
|
|
510
|
+
/** A version that is not a URL's ref has none of a slash, a backslash, `:`, or `@`: those mark a path or a spec, not a version. */
|
|
511
|
+
const VERSION = /^[^/\\:@]*$/;
|
|
512
|
+
/** What a version may hold: word characters and the range operators. Anything else (`#`, `&`, `?`, `;`, `%`) is a secret or a path in the shape of a version. */
|
|
513
|
+
const PLAIN_VERSION = /^[\w.^~*+<>=!,|\s-]*$/;
|
|
514
|
+
/** A space between range parts is allowed (`>=1 <2`), but a word of prose in a version is not: one opening with a letter other than `v` or `x` (`a secret token`). */
|
|
515
|
+
const holdsProse = (version) => /\s/.test(version) && version.split(/\s+/).some((word) => /^[a-uwyz]/i.test(word));
|
|
516
|
+
const withoutCredentials = (name) => name
|
|
517
|
+
.replace(/[?#].*$/, '')
|
|
518
|
+
.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, '$1')
|
|
519
|
+
.replace(/^[^@/\s:]+@(?=[^:/\s]+:)/, '');
|
|
520
|
+
/** What a word outside every grammar is: a path when it holds a separator, a scheme, or an `@` past the scope. */
|
|
521
|
+
const shapeOfJunk = (name) => {
|
|
522
|
+
if (VARIABLE.test(name))
|
|
523
|
+
return 'variable-name';
|
|
524
|
+
if (MASKED.test(name))
|
|
525
|
+
return 'masked-name';
|
|
526
|
+
return /[\\/]|^[A-Za-z][A-Za-z0-9+.-]*:/.test(name) || name.slice(1).includes('@') ? 'local-path' : 'unreadable-name';
|
|
527
|
+
};
|
|
528
|
+
const grammarOf = (cls, form, name) => {
|
|
529
|
+
if (name.length > (cls === 'mcp-server' || (cls === 'skill' && form === 'skill-call') ? LABEL_LENGTH : NAME_LENGTH))
|
|
530
|
+
return false;
|
|
531
|
+
if (cls === 'mcp-server')
|
|
532
|
+
return MCP_NAME.test(name);
|
|
533
|
+
if (cls === 'skill')
|
|
534
|
+
return form === 'skill-call' ? SKILL_NAME.test(name) : SKILL_SOURCE.test(name) || URL_NAME.test(name);
|
|
535
|
+
if (form === 'url-install' || form === 'url-fetch-and-run')
|
|
536
|
+
return URL_NAME.test(name) || SCP_NAME.test(name) || HOSTED_NAME.test(name) || SHORTHAND_NAME.test(name);
|
|
537
|
+
return (cls === 'npm-package' ? NPM_NAME : PEP508_NAME).test(name);
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* The one point every recorded name passes, before it reaches `crossings.jsonl`, a registry question, or a removal match. A name outside
|
|
541
|
+
* its family's grammar is never written and never asked: it is counted by the shape it has. A URL loses its credentials, query, and
|
|
542
|
+
* fragment first.
|
|
543
|
+
*/
|
|
544
|
+
export const admitName = (cls, form, name) => {
|
|
545
|
+
const urlForm = form === 'url-install' || form === 'url-fetch-and-run' || (cls === 'skill' && form === 'install');
|
|
546
|
+
const kept = urlForm ? withoutCredentials(name) : name;
|
|
547
|
+
return grammarOf(cls, form, kept) ? { kind: 'named', name: kept } : { kind: 'unread', shape: shapeOfJunk(kept) };
|
|
548
|
+
};
|
|
549
|
+
const admitted = (check) => {
|
|
550
|
+
const unread = [...check.unread];
|
|
551
|
+
const crossings = [];
|
|
552
|
+
for (const crossing of check.crossings) {
|
|
553
|
+
const verdict = admitName(crossing.class, crossing.form, crossing.name);
|
|
554
|
+
if (verdict.kind === 'unread')
|
|
555
|
+
unread.push(verdict.shape);
|
|
556
|
+
else if (crossing.versionForm !== 'url' && crossing.version !== null && !VERSION.test(crossing.version))
|
|
557
|
+
unread.push('local-path');
|
|
558
|
+
else if (crossing.versionForm !== 'url' && crossing.version !== null && (!PLAIN_VERSION.test(crossing.version) || holdsProse(crossing.version)))
|
|
559
|
+
unread.push('unreadable-name');
|
|
560
|
+
else
|
|
561
|
+
crossings.push({ ...crossing, name: verdict.name, version: crossing.versionForm === 'url' && crossing.version !== null && !PLAIN_REF.test(crossing.version) ? null : crossing.version });
|
|
562
|
+
}
|
|
563
|
+
const removals = check.removals.filter((removal) => admitName(removal.class, 'removal', removal.name).kind === 'named');
|
|
564
|
+
return { crossings, unread, removals };
|
|
565
|
+
};
|
|
566
|
+
/** The harness's own tool names: a shell call and the two tools that name a pick. */
|
|
567
|
+
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
568
|
+
/** What each tool call states, before the grammar of `admitName`. */
|
|
569
|
+
const readCall = (call, redact) => {
|
|
570
|
+
if (SHELL_TOOLS.has(call.tool))
|
|
571
|
+
return checkShell(call.input, redact);
|
|
572
|
+
if (call.tool.startsWith('mcp__')) {
|
|
573
|
+
const server = call.tool.split('__')[1] ?? '';
|
|
574
|
+
if (server === '')
|
|
575
|
+
return emptyReading();
|
|
576
|
+
return { ...emptyReading(), crossings: [{ class: 'mcp-server', form: 'mcp-call', name: redact(server), version: null, versionForm: 'unstated', program: 'mcp', statusReports: true, registryAsked: true }] };
|
|
577
|
+
}
|
|
578
|
+
const skill = call.tool === 'Skill' ? (stringField(call.input, 'skill') ?? stringField(call.input, 'command')) : undefined;
|
|
579
|
+
if (skill === undefined)
|
|
580
|
+
return emptyReading();
|
|
581
|
+
return { ...emptyReading(), crossings: [{ class: 'skill', form: 'skill-call', name: redact(skill), version: null, versionForm: 'unstated', program: 'skill', statusReports: true, registryAsked: true }] };
|
|
582
|
+
};
|
|
583
|
+
/** Names the crossing a tool call makes, if any. The later hook calls this same function with the call it is handed. */
|
|
584
|
+
export const checkCall = (call, redact) => admitted(readCall(call, redact));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Outcome } from '../cli.js';
|
|
2
|
+
import type { SessionsDeps } from '../sessions/command.js';
|
|
3
|
+
import type { RegistryReader } from './registry.js';
|
|
4
|
+
/** Reads local logs only; a `registry` reader is passed solely by `--registry-state`. */
|
|
5
|
+
export declare const gateReplay: (deps: SessionsDeps, options: {
|
|
6
|
+
allow: string[];
|
|
7
|
+
registry?: RegistryReader;
|
|
8
|
+
}) => Promise<Outcome>;
|
|
9
|
+
export declare const gateReport: (deps: SessionsDeps) => Promise<Outcome>;
|
|
10
|
+
/** User scope only: the one place the harness reads for every project, and the one place this command ever writes. */
|
|
11
|
+
export declare const gateInstall: (deps: SessionsDeps) => Promise<Outcome>;
|
|
12
|
+
/** The policy in force for this store: the file's settings, or the defaults and why. Reads one file, writes none. */
|
|
13
|
+
export declare const gatePolicy: (deps: SessionsDeps) => Promise<Outcome>;
|
|
14
|
+
export declare const gateUninstall: (deps: SessionsDeps) => Promise<Outcome>;
|