@dev.sail.money/sailor 1.3.0-226 → 1.3.0-228

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.
Files changed (48) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +59 -498
  3. package/package.json +1 -5
  4. package/packages/cli/dist/index.cjs +53 -22
  5. package/packages/sdk/dist/fees.d.ts +2 -1
  6. package/packages/sdk/dist/fees.d.ts.map +1 -1
  7. package/packages/sdk/dist/fees.js +2 -1
  8. package/packages/sdk/dist/fees.js.map +1 -1
  9. package/packages/sdk/dist/intelligence.d.ts +1 -1
  10. package/packages/sdk/dist/intelligence.js +1 -1
  11. package/packages/sdk/package.json +2 -2
  12. package/templates/default/.agents/skills/sail-mandates/references/examples-index.md +3 -3
  13. package/templates/default/.agents/skills/sail-template-swap/SKILL.md +2 -2
  14. package/templates/default/.agents/skills/sail-template-swap-no-oracle/SKILL.md +2 -2
  15. package/templates/default/.agents/skills/sail-templates/SKILL.md +2 -2
  16. package/templates/default/docs/PERMISSION_MODEL.md +2 -2
  17. package/{examples → templates/default/examples}/custom-mandate/.sail/contracts/interfaces/IPermission.sol +4 -0
  18. package/{examples → templates/default/examples}/custom-mandate/README.md +4 -1
  19. package/{examples → templates/default/examples}/permissions/interfaces/IBatchPermission.sol +2 -0
  20. package/{examples → templates/default/examples}/permissions/interfaces/IPermission.sol +4 -0
  21. package/templates/default/test/BoundedCallPermission.t.sol +2 -1
  22. package/docs/PERMISSION_MODEL.md +0 -93
  23. package/examples/README.md +0 -24
  24. package/examples/lifi-permissions/LifiBoundedApprovePermissionCloneable.sol +0 -84
  25. package/examples/lifi-permissions/LifiDiamondSwapPermissionCloneable.sol +0 -97
  26. package/examples/lifi-permissions/README.md +0 -53
  27. package/scripts/check-docs.mjs +0 -309
  28. package/scripts/check-init.mjs +0 -123
  29. package/scripts/check-update.mjs +0 -177
  30. package/scripts/clean.mjs +0 -17
  31. /package/{examples → templates/default/examples}/custom-mandate/foundry.toml +0 -0
  32. /package/{examples → templates/default/examples}/custom-mandate/mandates/BoundedCallPermission.sol +0 -0
  33. /package/{examples → templates/default/examples}/custom-mandate/mandates/README.md +0 -0
  34. /package/{examples → templates/default/examples}/custom-mandate/mandates/SailCalldata.sol +0 -0
  35. /package/{examples → templates/default/examples}/permissions/BoundedApproveAndCallBatch.sol +0 -0
  36. /package/{examples → templates/default/examples}/permissions/BoundedBorrow_AaveV3_Arbitrum.sol +0 -0
  37. /package/{examples → templates/default/examples}/permissions/BoundedLimitless_Base.sol +0 -0
  38. /package/{examples → templates/default/examples}/permissions/BoundedPerp_GMXv2_Arbitrum.sol +0 -0
  39. /package/{examples → templates/default/examples}/permissions/BoundedStake_Venice_Base.sol +0 -0
  40. /package/{examples → templates/default/examples}/permissions/BoundedSupply_AaveV3_Arbitrum.sol +0 -0
  41. /package/{examples → templates/default/examples}/permissions/BoundedSwapNative_UniswapV3_Base.sol +0 -0
  42. /package/{examples → templates/default/examples}/permissions/BoundedSwap_UniswapV3_Base.sol +0 -0
  43. /package/{examples → templates/default/examples}/permissions/BoundedSwap_UniswapV4_Unichain.sol +0 -0
  44. /package/{examples → templates/default/examples}/permissions/BoundedTransfer_ERC20_Ethereum.sol +0 -0
  45. /package/{examples → templates/default/examples}/permissions/BoundedVault_ERC4626_Base.sol +0 -0
  46. /package/{examples → templates/default/examples}/permissions/README.md +0 -0
  47. /package/{examples → templates/default/examples}/permissions/SailCalldata.sol +0 -0
  48. /package/{examples → templates/default/examples}/permissions/foundry.toml +0 -0
@@ -1,97 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
3
-
4
- import {IPermission, Context} from "../../.sail/contracts/interfaces/IPermission.sol";
5
- import {CloneInitializable} from "../../.sail/contracts/templates/base/CloneInitializable.sol";
6
-
7
- /// @title LifiDiamondSwapPermissionCloneable
8
- /// @notice EIP-1167 clone-template version of LifiDiamondSwapPermission. The logic
9
- /// contract is deployed once and registered in the SDK's standaloneTemplates;
10
- /// each account gets its own clone via PermissionFactory.deployAndAttach,
11
- /// configured through initialize() (NOT the constructor).
12
- ///
13
- /// Restricts manager-initiated swaps to the official LiFi Diamond on Base:
14
- /// - target must be the LiFi Diamond,
15
- /// - selector must be allowlisted,
16
- /// - receiver embedded in the calldata must equal ctx.account (the SMA),
17
- /// - the minAmount field must not exceed the configured cap.
18
- /// Passes through any call whose target is not the diamond (conjunctive model).
19
- ///
20
- /// Calldata layout (validated against live Base quotes):
21
- /// selector(4) + word0(32) + word1(32) + word2(32) + receiver(32) + minAmount(32)
22
- /// → receiver at offset 100, minAmount at offset 132.
23
- contract LifiDiamondSwapPermissionCloneable is IPermission, CloneInitializable {
24
- // Official LiFi Diamond on Base Mainnet.
25
- address public constant LIFI_DIAMOND = 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE;
26
-
27
- // Storage starts after CloneInitializable's `_initialized` bool (slot 0). A
28
- // mapping occupies its own slot pointer and does not pack with the bool.
29
- mapping(bytes4 selector => bool) public isAllowedSelector;
30
- uint256 public maxMinAmountPerTx;
31
- address public permissionSigner;
32
-
33
- error NotPermissionSigner();
34
- error ZeroAddress();
35
-
36
- modifier onlyPermissionSigner() {
37
- if (msg.sender != permissionSigner) revert NotPermissionSigner();
38
- _;
39
- }
40
-
41
- /// @dev Lock the logic contract; only clones (fresh storage) can be initialized.
42
- constructor() {
43
- _disableInitializers();
44
- }
45
-
46
- /// @notice One-time per-clone configuration.
47
- /// @param allowedSelectors LiFi Diamond selectors to allowlist on init.
48
- /// @param _maxMinAmountPerTx Cap on the minAmount field (type(uint256).max = uncapped).
49
- /// @param _permissionSigner Owner wallet; sole authority for post-init updates.
50
- function initialize(
51
- bytes4[] memory allowedSelectors,
52
- uint256 _maxMinAmountPerTx,
53
- address _permissionSigner
54
- ) external initializer {
55
- if (_permissionSigner == address(0)) revert ZeroAddress();
56
- maxMinAmountPerTx = _maxMinAmountPerTx;
57
- permissionSigner = _permissionSigner;
58
- for (uint256 i; i < allowedSelectors.length; i++) {
59
- isAllowedSelector[allowedSelectors[i]] = true;
60
- }
61
- }
62
-
63
- function setMaxMinAmountPerTx(uint256 newMax) external onlyPermissionSigner {
64
- maxMinAmountPerTx = newMax;
65
- }
66
-
67
- /// @notice Add a LiFi selector. Only after verifying its calldata places
68
- /// `receiver` at offset 100 and `minAmount` at offset 132.
69
- function addSelector(bytes4 selector) external onlyPermissionSigner {
70
- isAllowedSelector[selector] = true;
71
- }
72
-
73
- function removeSelector(bytes4 selector) external onlyPermissionSigner {
74
- isAllowedSelector[selector] = false;
75
- }
76
-
77
- uint256 private constant RECEIVER_OFFSET = 100;
78
- uint256 private constant MIN_DATA_LEN = 164;
79
-
80
- function evaluate(bytes calldata txData, Context calldata ctx) external view returns (bool) {
81
- // Pass through calls outside this permission's domain (conjunctive model).
82
- if (ctx.target != LIFI_DIAMOND) return true;
83
- if (!isAllowedSelector[ctx.selector]) return false;
84
- if (txData.length < MIN_DATA_LEN) return false;
85
-
86
- address receiver = abi.decode(txData[RECEIVER_OFFSET:RECEIVER_OFFSET + 32], (address));
87
- uint256 minAmount = abi.decode(txData[RECEIVER_OFFSET + 32:RECEIVER_OFFSET + 64], (uint256));
88
-
89
- if (receiver != ctx.account) return false;
90
- if (minAmount > maxMinAmountPerTx) return false;
91
- return true;
92
- }
93
-
94
- function discriminator() external pure returns (bytes32) {
95
- return keccak256("LifiDiamondSwapPermissionCloneable.v1");
96
- }
97
- }
@@ -1,53 +0,0 @@
1
- # LiFi clone-permission templates
2
-
3
- Canonical source (for reference) of the EIP-1167 **clone** permission templates
4
- used for LiFi-based swaps/DCA. The logic contracts are deployed once per chain and
5
- registered in the SDK deployment registry
6
- (`packages/sdk/src/deployments.ts` → `standaloneTemplates` / `cloneTemplates`).
7
- Each account gets its own clone via `PermissionFactory.deployAndAttach`, configured
8
- through `initialize()` (never the constructor).
9
-
10
- These follow the `CloneInitializable` pattern: the constructor calls
11
- `_disableInitializers()` to permanently lock the logic contract, and a one-time
12
- `initialize()` (guarded by the `initializer` modifier) configures each clone.
13
-
14
- > The `import` paths reference `../../.sail/contracts/{interfaces,templates/base}`
15
- > from the Foundry project they were built in
16
- > (`tests/base-mainnet-agent-01/`). This folder is **reference source** — sailor has
17
- > no Foundry build. Build/deploy happens in that project via
18
- > `scripts/deploy-clone-templates.ts`.
19
-
20
- > **Note:** The kernels bundled in `@sail.money/sailor/sdk` (Base, Base Sepolia, Arbitrum, Unichain) now all run the **selective** dispatch model — verified on-chain against each kernel's `DISPATCH_TYPEHASH`. These templates were written for the older conjunctive model and include pass-through logic (`return true` for calls outside their domain) that is not required on selective kernels. Review before deploying against a selective kernel; the pass-through logic is harmless but unnecessary.
21
-
22
- ## Contracts
23
-
24
- ### LifiDiamondSwapPermissionCloneable → `boundedLiFi`
25
- Restricts manager swaps to the official LiFi Diamond:
26
- target == diamond, selector allowlisted, embedded receiver == `ctx.account`,
27
- `minAmount <= maxMinAmountPerTx`. Passes through any call whose target is not the
28
- diamond (conjunctive-kernel rule).
29
-
30
- `initialize(bytes4[] allowedSelectors, uint256 maxMinAmountPerTx, address permissionSigner)`
31
-
32
- ### LifiBoundedApprovePermissionCloneable → `boundedApprove`
33
- Approve only the LiFi Diamond, only on tokens with a configured cap, up to that
34
- cap. **Per-token caps** (`mapping(token => cap)`) because token value/decimals
35
- differ (1 DAI = 1e18 vs 1 USDC = 1e6). Passes through non-approve calls.
36
-
37
- `initialize(address[] tokens, uint256[] caps, address permissionSigner)`
38
-
39
- ## Deployed logic addresses
40
-
41
- | Template | Chain | Address |
42
- |---|---|---|
43
- | boundedLiFi | Base mainnet (8453) | `0xF1abcF774250fD1A8147B56DA07Bf9021064650A` |
44
- | boundedApprove | Base mainnet (8453) | `0x9c0b86daf9e75d759a5D165aD7366e52b3353fD8` |
45
-
46
- Both verified `initialized() == true` (logic locked) post-deploy.
47
-
48
- ## Conjunctive-kernel note
49
-
50
- Both Base kernels use the **conjunctive** dispatch model: every registered
51
- permission is evaluated and ALL must return true. So a permission MUST pass through
52
- calls outside its own domain (return `true`), or it bricks unrelated dispatches.
53
- Both templates above do this.
@@ -1,309 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Doc-drift gate.
4
- *
5
- * The agent-facing docs (CLAUDE.md, AGENTS.md, AGENT_PLAYBOOK.md, the scaffolded
6
- * template, package READMEs) tell an LLM agent which `sailor` commands and which
7
- * `client.<ns>.<method>(...)` SDK calls to use. If a doc names a command or method
8
- * that no longer exists, the agent confidently does the wrong thing. This script
9
- * is the regression net: it derives the *real* surface from source and fails if a
10
- * doc references something that isn't there.
11
- *
12
- * - CLI commands ← parsed from packages/cli/src/index.ts (commander tree)
13
- * - SDK methods ← parsed from packages/sdk/src/client.ts (namespace classes)
14
- *
15
- * Only `sailor …` is validated, never the SailFramework `sail …` binary that some
16
- * docs reference. Only references inside `inline code` or ```fenced``` blocks are
17
- * checked, so prose ("the sailor CLI") never trips it.
18
- *
19
- * Run: `node scripts/check-docs.mjs` (or `pnpm docs:check`). Exit 1 on any miss.
20
- */
21
-
22
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
23
- import { dirname, join, relative } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
-
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
27
- const rel = (p) => relative(ROOT, p);
28
-
29
- // ── 1. Derive the real CLI command surface ──────────────────────────────────
30
-
31
- /**
32
- * Parse the commander tree from index.ts into:
33
- * leaves — Set of full top-level invocations ("init", "doctor", "dispatch preview")
34
- * groups — Map<groupName, Set<subcommand>> (e.g. "mandate" → {prepare, sign, …})
35
- */
36
- function parseCliSurface() {
37
- const src = readFileSync(join(ROOT, "packages/cli/src/index.ts"), "utf-8");
38
- const leaves = new Set();
39
- const groups = new Map();
40
- const varToGroup = new Map(); // commander variable name → group command name
41
-
42
- // `const ui = program.command("ui")` — a group declared on a variable.
43
- for (const m of src.matchAll(/const\s+(\w+)\s*=\s*program\s*\.command\(\s*"([^"]+)"/g)) {
44
- const groupName = m[2].split(/\s+/)[0];
45
- varToGroup.set(m[1], groupName);
46
- if (!groups.has(groupName)) groups.set(groupName, new Set());
47
- }
48
-
49
- // `program.command("init [dir]")` — a top-level leaf (not assigned to a var).
50
- for (const m of src.matchAll(/program\s*\.command\(\s*"([^"]+)"/g)) {
51
- const name = m[1].split(/\s+/)[0];
52
- // Multi-word leaf names (e.g. the "dispatch preview" stub) keep their full form too.
53
- const full = m[1]
54
- .replace(/\s*\[.*$/, "")
55
- .replace(/\s*<.*$/, "")
56
- .trim();
57
- if (!groups.has(name)) {
58
- leaves.add(name);
59
- if (full.includes(" ")) leaves.add(full);
60
- }
61
- }
62
-
63
- // `stub("setup", …)` / `stub("dispatch preview", …)` — registered top-level commands.
64
- for (const m of src.matchAll(/\bstub\(\s*"([^"]+)"/g)) {
65
- leaves.add(m[1]);
66
- leaves.add(m[1].split(/\s+/)[0]);
67
- }
68
-
69
- // `ui.command("start")` / `mandate.command("prepare")` — subcommands of a group.
70
- for (const m of src.matchAll(/(\w+)\s*\.command\(\s*"([^"]+)"/g)) {
71
- const groupName = varToGroup.get(m[1]);
72
- if (!groupName) continue; // not a known group variable (e.g. program.command handled above)
73
- const sub = m[2].split(/\s+/)[0];
74
- groups.get(groupName).add(sub);
75
- }
76
-
77
- return { leaves, groups };
78
- }
79
-
80
- // ── 2. Derive the real SDK surface ───────────────────────────────────────────
81
-
82
- /**
83
- * Parse client.ts into:
84
- * namespaces — Map<propertyName, Set<methodName>> (account → {create, get, …})
85
- * clientMethods — Set of direct SailorClient methods (capabilities, withSigner, …)
86
- */
87
- function parseSdkSurface() {
88
- const src = readFileSync(join(ROOT, "packages/sdk/src/client.ts"), "utf-8");
89
- const lines = src.split("\n");
90
-
91
- // Map namespace *class* → set of method names, by slicing each class body.
92
- const classMethods = new Map();
93
- let current = null;
94
- let depth = 0;
95
- const reserved = new Set([
96
- "if",
97
- "for",
98
- "while",
99
- "switch",
100
- "catch",
101
- "return",
102
- "super",
103
- "constructor",
104
- "function",
105
- "await",
106
- "typeof",
107
- "new",
108
- ]);
109
- for (const line of lines) {
110
- const classMatch = line.match(/^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/);
111
- if (classMatch) {
112
- current = classMatch[1];
113
- classMethods.set(current, new Set());
114
- depth = 0;
115
- }
116
- if (current) {
117
- // Method declarations live at class-body depth 1.
118
- const methodMatch = line.match(
119
- /^\s{2}(?:public\s+|private\s+|protected\s+)?(?:async\s+)?(?:get\s+)?(\w+)\s*[(<]/,
120
- );
121
- if (methodMatch && !reserved.has(methodMatch[1])) {
122
- classMethods.get(current).add(methodMatch[1]);
123
- }
124
- depth += (line.match(/{/g)?.length ?? 0) - (line.match(/}/g)?.length ?? 0);
125
- if (depth <= 0 && line.includes("}")) current = null;
126
- }
127
- }
128
-
129
- // Map runtime property name → class: `this.account = new AccountNamespace(`
130
- const namespaces = new Map();
131
- for (const m of src.matchAll(/this\.(\w+)\s*=\s*new\s+(\w+)\s*\(/g)) {
132
- const [, prop, cls] = m;
133
- if (classMethods.has(cls)) namespaces.set(prop, classMethods.get(cls));
134
- }
135
- // `this.dispatch = dispatch;` — assigned from a local built earlier in the ctor.
136
- for (const m of src.matchAll(/this\.(\w+)\s*=\s*(\w+);/g)) {
137
- const [, prop, localVar] = m;
138
- if (namespaces.has(prop)) continue;
139
- // Resolve the local: `const dispatch = new DispatchNamespace(`
140
- const localDecl = new RegExp(`(?:const|let)\\s+${localVar}\\s*=\\s*new\\s+(\\w+)\\s*\\(`);
141
- const lm = src.match(localDecl);
142
- if (lm && classMethods.has(lm[1])) namespaces.set(prop, classMethods.get(lm[1]));
143
- }
144
-
145
- // Direct methods on the SailorClient class itself.
146
- const clientMethods = classMethods.get("SailorClient") ?? new Set();
147
-
148
- return { namespaces, clientMethods };
149
- }
150
-
151
- // ── 3. Collect doc files + extract code references ───────────────────────────
152
-
153
- const DOC_GLOBS = [
154
- "CLAUDE.md",
155
- "AGENTS.md",
156
- "AGENT_PLAYBOOK.md",
157
- "README.md",
158
- "docs",
159
- "templates",
160
- "packages/cli/README.md",
161
- "packages/sdk/README.md",
162
- ];
163
-
164
- function collectDocs() {
165
- const files = [];
166
- const walk = (p) => {
167
- const st = statSync(p, { throwIfNoEntry: false });
168
- if (!st) return;
169
- if (st.isDirectory()) {
170
- for (const e of readdirSync(p)) {
171
- if (e === "node_modules" || e === "dist" || e.startsWith(".git")) continue;
172
- walk(join(p, e));
173
- }
174
- } else if (p.endsWith(".md")) {
175
- files.push(p);
176
- }
177
- };
178
- for (const g of DOC_GLOBS) walk(join(ROOT, g));
179
- return [...new Set(files)];
180
- }
181
-
182
- /** Pull the text of every `inline` and ```fenced``` code region from markdown. */
183
- function codeRegions(md) {
184
- const regions = [];
185
- // Fenced blocks first, then strip them so inline matching doesn't double-count.
186
- const rest = md.replace(/```[\s\S]*?```/g, (block) => {
187
- regions.push(block.replace(/```/g, ""));
188
- return "\n";
189
- });
190
- for (const m of rest.matchAll(/`([^`\n]+)`/g)) regions.push(m[1]);
191
- return regions;
192
- }
193
-
194
- // ── 4. Skills consistency ─────────────────────────────────────────────────────
195
- //
196
- // The scaffolded template ships agent skills under .agents/skills/. AGENTS.md is
197
- // the routing layer: it must point at every skill that exists, and every skill it
198
- // points at must exist with valid frontmatter — otherwise an agent either never
199
- // discovers a workflow or follows a dangling pointer.
200
-
201
- function checkSkills(errors) {
202
- const skillsRoot = join(ROOT, "templates/default/.agents/skills");
203
- const agentsPath = join(ROOT, "templates/default/AGENTS.md");
204
- if (!existsSync(skillsRoot)) {
205
- errors.push("templates/default/.agents/skills: directory missing");
206
- return;
207
- }
208
- const agentsMd = readFileSync(agentsPath, "utf-8");
209
- const dirs = readdirSync(skillsRoot).filter((d) =>
210
- statSync(join(skillsRoot, d)).isDirectory(),
211
- );
212
- if (dirs.length === 0) errors.push("templates/default/.agents/skills: no skills found");
213
-
214
- for (const d of dirs) {
215
- const skillFile = join(skillsRoot, d, "SKILL.md");
216
- if (!existsSync(skillFile)) {
217
- errors.push(`templates/default/.agents/skills/${d}: missing SKILL.md`);
218
- continue;
219
- }
220
- const fm = readFileSync(skillFile, "utf-8").match(/^---\r?\n([\s\S]*?)\r?\n---/);
221
- if (!fm) {
222
- errors.push(`${rel(skillFile)}: missing YAML frontmatter`);
223
- continue;
224
- }
225
- const name = fm[1].match(/^name:\s*(\S+)\s*$/m)?.[1];
226
- const description = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
227
- if (name !== d) errors.push(`${rel(skillFile)}: frontmatter name "${name}" ≠ directory "${d}"`);
228
- if (!description) errors.push(`${rel(skillFile)}: frontmatter description missing or empty`);
229
- if (!agentsMd.includes(`.agents/skills/${d}/SKILL.md`)) {
230
- errors.push(`templates/default/AGENTS.md: routing table does not reference .agents/skills/${d}/SKILL.md`);
231
- }
232
- }
233
-
234
- for (const m of agentsMd.matchAll(/\.agents\/skills\/([\w-]+)\/SKILL\.md/g)) {
235
- if (!dirs.includes(m[1])) {
236
- errors.push(`templates/default/AGENTS.md: references .agents/skills/${m[1]}/SKILL.md which does not exist`);
237
- }
238
- }
239
- }
240
-
241
- // ── 5. Validate ──────────────────────────────────────────────────────────────
242
-
243
- function main() {
244
- const cli = parseCliSurface();
245
- const sdk = parseSdkSurface();
246
- const docs = collectDocs();
247
- const errors = [];
248
-
249
- for (const file of docs) {
250
- const md = readFileSync(file, "utf-8");
251
- for (const code of codeRegions(md)) {
252
- for (const lineRaw of code.split("\n")) {
253
- const line = lineRaw.trim();
254
-
255
- // ── CLI: `sailor <word1> [<word2>]` ──────────────────────────────────
256
- for (const m of line.matchAll(/\bsailor\s+([a-z][\w-]*)(?:\s+([a-z][\w-]*))?/g)) {
257
- const [, w1, w2] = m;
258
- const two = w2 ? `${w1} ${w2}` : null;
259
- if (cli.leaves.has(w1) || (two && cli.leaves.has(two))) continue; // top-level leaf
260
- if (cli.groups.has(w1)) {
261
- // Group: if a subcommand word follows (and isn't a flag), it must exist.
262
- if (!w2 || w2.startsWith("-")) continue; // bare group invocation, or a flag
263
- if (cli.groups.get(w1).has(w2)) continue;
264
- errors.push(
265
- `${rel(file)}: \`sailor ${w1} ${w2}\` — "${w2}" is not a subcommand of "${w1}" (have: ${[...cli.groups.get(w1)].join(", ")})`,
266
- );
267
- continue;
268
- }
269
- errors.push(`${rel(file)}: \`sailor ${w1}\` — unknown command`);
270
- }
271
-
272
- // ── SDK: `client.<ns>[.<method>]` ────────────────────────────────────
273
- for (const m of line.matchAll(/\bclient\.(\w+)(?:\.(\w+))?/g)) {
274
- const [, ns, method] = m;
275
- if (sdk.namespaces.has(ns)) {
276
- if (!method) continue;
277
- if (sdk.namespaces.get(ns).has(method)) continue;
278
- errors.push(
279
- `${rel(file)}: \`client.${ns}.${method}\` — "${method}" is not a method of the "${ns}" namespace (have: ${[...sdk.namespaces.get(ns)].join(", ")})`,
280
- );
281
- continue;
282
- }
283
- if (sdk.clientMethods.has(ns)) continue; // direct client method, e.g. capabilities/withSigner
284
- errors.push(`${rel(file)}: \`client.${ns}\` — unknown SDK namespace or method`);
285
- }
286
- }
287
- }
288
- }
289
-
290
- checkSkills(errors);
291
-
292
- // ── Report ───────────────────────────────────────────────────────────────
293
- const cliCount = cli.leaves.size + [...cli.groups.values()].reduce((n, s) => n + s.size, 0);
294
- const sdkCount =
295
- [...sdk.namespaces.values()].reduce((n, s) => n + s.size, 0) + sdk.clientMethods.size;
296
- console.log(
297
- `Doc-drift check: ${docs.length} docs vs ${cliCount} CLI commands, ${sdkCount} SDK methods`,
298
- );
299
-
300
- if (errors.length > 0) {
301
- console.error(`\n✗ ${errors.length} stale reference(s):\n`);
302
- for (const e of [...new Set(errors)].sort()) console.error(` ${e}`);
303
- console.error("\nFix the doc, or update the CLI/SDK so the referenced surface exists.");
304
- process.exit(1);
305
- }
306
- console.log("✓ Every sailor command and client.* method referenced in docs exists.");
307
- }
308
-
309
- main();
@@ -1,123 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * `sailor init` smoke test.
4
- *
5
- * PASS 1 — fresh init: scaffolds a new project and asserts expected files exist.
6
- *
7
- * Template files are read live from disk (not bundled), so no rebuild is needed
8
- * between runs — the in-tree templates/default/ IS the "latest version".
9
- *
10
- * Run: node scripts/check-init.mjs (CI builds the CLI first)
11
- * Exit: 0 = all passes OK, 1 = failure (prints what went wrong).
12
- */
13
-
14
- import { execFileSync } from "node:child_process";
15
- import fs from "node:fs";
16
- import os from "node:os";
17
- import path from "node:path";
18
- import { fileURLToPath } from "node:url";
19
-
20
- const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
21
- const BUNDLE = path.join(ROOT, "packages/cli/dist/index.cjs");
22
- const PROJECT = "smoke-agent";
23
-
24
- function fail(msg) {
25
- console.error(`✗ init smoke test FAILED: ${msg}`);
26
- process.exit(1);
27
- }
28
-
29
- if (!fs.existsSync(BUNDLE)) {
30
- fail(`CLI bundle not found at ${BUNDLE}.\n Build it first: pnpm --filter sailor build`);
31
- }
32
-
33
- // Scaffold into a temp dir. `init` requires the destination to live inside the
34
- // process cwd, so we run the bundle with cwd set to a fresh temp root.
35
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sailor-init-smoke-"));
36
- const dest = path.join(tmpRoot, PROJECT);
37
-
38
- try {
39
- let stdout = "";
40
- try {
41
- stdout = execFileSync(process.execPath, [BUNDLE, "init", PROJECT], {
42
- cwd: tmpRoot,
43
- encoding: "utf-8",
44
- stdio: ["ignore", "pipe", "pipe"],
45
- });
46
- } catch (err) {
47
- const out = `${err.stdout ?? ""}${err.stderr ?? ""}`.trim();
48
- fail(`\`sailor init ${PROJECT}\` exited non-zero.\n ${out || err.message}`);
49
- }
50
-
51
- // A successful fresh init prints the AGENTS.md onboarding banner.
52
- if (!/AGENTS\.md/i.test(stdout)) {
53
- fail(`init did not report success.\n stdout: ${stdout.trim()}`);
54
- }
55
-
56
- // Assert the scaffold landed.
57
- const mustExist = [
58
- ".sail/config.json",
59
- "package.json",
60
- "foundry.toml",
61
- "mandates",
62
- "AGENTS.md",
63
- "CLAUDE.md",
64
- "Dockerfile",
65
- ".dockerignore",
66
- ".sail/contracts/interfaces/IPermission.sol",
67
- ".sail/contracts/interfaces/IBatchPermission.sol",
68
- "test/BoundedCallPermission.t.sol",
69
- "examples/custom-mandate/README.md",
70
- ".agents/skills/sail-onboarding/SKILL.md",
71
- ".agents/skills/sail-project-info/SKILL.md",
72
- ".agents/skills/sail-servers/SKILL.md",
73
- ".agents/skills/sail-transactions/SKILL.md",
74
- ".agents/skills/sail-mandates/SKILL.md",
75
- ".agents/skills/sail-mandates/references/approvals.md",
76
- ".agents/skills/sail-automation/SKILL.md",
77
- ".agents/skills/sail-automation/references/docker-vm.md",
78
- ".agents/skills/sail-automation/references/github-actions.md",
79
- ".agents/skills/sail-automation/references/local-daemon.md",
80
- ".agents/skills/sail-automation/references/self-hosted-runner.md",
81
- ".agents/skills/sail-extend/SKILL.md",
82
- ];
83
- for (const rel of mustExist) {
84
- if (!fs.existsSync(path.join(dest, rel))) fail(`expected scaffolded "${rel}" — not found`);
85
- }
86
-
87
- // config.json is valid JSON named after the project.
88
- const config = JSON.parse(fs.readFileSync(path.join(dest, ".sail/config.json"), "utf-8"));
89
- if (config.name !== PROJECT) fail(`config.json name is "${config.name}", expected "${PROJECT}"`);
90
-
91
- // package.json is valid, renamed, and the workspace protocol was resolved away
92
- // (a leftover "workspace:*" would make the scaffold un-installable for users).
93
- const pkg = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf-8"));
94
- if (pkg.name !== PROJECT) fail(`package.json name is "${pkg.name}", expected "${PROJECT}"`);
95
- if (pkg.dependencies?.["@sail/sdk"] === "workspace:*") {
96
- fail(`package.json still has "@sail/sdk": "workspace:*" — init did not resolve it`);
97
- }
98
-
99
- // ── Regression guard: absolute path outside cwd ───────────────────────────
100
- // An absolute path outside the cwd must be REJECTED, not silently nested into
101
- // `<cwd>/<abs path>`. (Pre-fix, `path.join` swallowed the leading slash and
102
- // scaffolded a bogus nested tree while printing success.)
103
- const outside = path.join(os.tmpdir(), "sailor-init-outside", "agent");
104
- let rejected = false;
105
- try {
106
- execFileSync(process.execPath, [BUNDLE, "init", outside], {
107
- cwd: tmpRoot,
108
- stdio: ["ignore", "pipe", "pipe"],
109
- });
110
- } catch {
111
- rejected = true; // non-zero exit = correctly refused
112
- }
113
- if (!rejected) fail(`an absolute path outside cwd ("${outside}") was accepted — should be rejected`);
114
- if (fs.existsSync(path.join(outside, ".sail/config.json"))) {
115
- fail(`init scaffolded into an out-of-cwd absolute path "${outside}"`);
116
- }
117
-
118
- console.log(`✓ init smoke test passed — scaffolded ${PROJECT}/ from the in-tree bundle`);
119
- console.log("✓ init guard passed — absolute path outside cwd rejected, not silently nested");
120
- } finally {
121
- fs.rmSync(path.join(os.tmpdir(), "sailor-init-outside"), { recursive: true, force: true });
122
- fs.rmSync(tmpRoot, { recursive: true, force: true });
123
- }