@codyswann/lisa 2.320.0 → 2.321.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/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +6 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +12 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +124 -5
- package/scripts/lisa-remote-env/session-start.sh +30 -0
- package/scripts/lisa-remote-env/setup.sh +69 -0
package/package.json
CHANGED
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"brace-expansion": ">=5.0.8"
|
|
116
116
|
},
|
|
117
117
|
"name": "@codyswann/lisa",
|
|
118
|
-
"version": "2.
|
|
118
|
+
"version": "2.321.0",
|
|
119
119
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
120
120
|
"main": "dist/index.js",
|
|
121
121
|
"exports": {
|
|
@@ -162,6 +162,18 @@ It reads the project's own install command from its lockfile and the bootstrap n
|
|
|
162
162
|
- **`gh` is not pre-installed.** If the project's flows shell out to it, add it to `remoteEnv.tools.install`, pinned and checksummed like anything else.
|
|
163
163
|
- **A proxied credential reads as the literal string `proxy-injected`.** Tools that authenticate through the GitHub proxy work; a script that reads the variable directly gets the placeholder. The read-back asserts this rather than leaving it to be discovered against a live service.
|
|
164
164
|
|
|
165
|
+
### One environment per project, pinned locally
|
|
166
|
+
|
|
167
|
+
An environment is **not** bound to a repository — the repository arrives per session, and one environment is technically reusable across all of them. Give each project its own anyway, because an environment's *contents* are project-shaped: its setup script is a repository-relative path and its bootstrap is scoped to that project's secrets. Pointing one project's session at another's environment runs a setup script that may not exist there, and a setup script that exits non-zero means the session never starts.
|
|
168
|
+
|
|
169
|
+
`/remote-env` writes `remote.defaultEnvironmentId` into **user** settings, which is one value for the whole machine — wrong as soon as a developer has two projects. Pin it per project instead:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
node scripts/setup-remote-env.mjs --install --pin-env=<environment-id>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
That writes `.claude/settings.local.json`, which outranks user settings and is gitignored — correct on both counts, since an environment belongs to one developer's account and is meaningless in someone else's checkout. Existing keys in that file are merged, not replaced; it commonly holds permission grants worth keeping.
|
|
176
|
+
|
|
165
177
|
**Only the bootstrap belongs in the environment-variable box.** Values there are stored as plain text and are readable by anyone who uses the environment — on an organization-shared environment, that is every member of the organization. Everything else is materialized by the session-start hook. There is no dedicated secrets store on this surface, and personal versus shared environments cannot be told apart programmatically, so this one is a rule the operator upholds rather than something the tooling can enforce.
|
|
166
178
|
|
|
167
179
|
## Verification is tier-independent
|
package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
mkdirSync,
|
|
36
36
|
readFileSync,
|
|
37
37
|
rmSync,
|
|
38
|
+
writeFileSync,
|
|
38
39
|
} from "node:fs";
|
|
39
40
|
import { dirname, join, resolve } from "node:path";
|
|
40
41
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -89,18 +90,35 @@ export function readRemoteEnvConfig(cwd = process.cwd()) {
|
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* Probe what a tool reports for its version, treating absence as not present.
|
|
93
|
+
*
|
|
94
|
+
* "Not installed" and "dislikes `--version`" are different answers, and only a
|
|
95
|
+
* failure to *spawn* means the first. Info-ZIP's `unzip` — the build shipped by
|
|
96
|
+
* both macOS and Ubuntu — parses `--version` one letter at a time, warns that
|
|
97
|
+
* `-n` and `-o` conflict, and exits 10. Reading that as absence made `require`
|
|
98
|
+
* fail on a machine that had the tool, which is precisely the false alarm the
|
|
99
|
+
* check exists to avoid raising.
|
|
100
|
+
*
|
|
101
|
+
* Version text is still recovered from a failed invocation where there is any,
|
|
102
|
+
* because a tool that refuses the flag often prints its banner anyway — which
|
|
103
|
+
* is how `unzip` still reports 6.00 despite exiting non-zero.
|
|
104
|
+
*
|
|
105
|
+
* The runner is injectable so both branches can be exercised without depending
|
|
106
|
+
* on which quirky binaries happen to exist on the machine running the tests.
|
|
92
107
|
* @param {string} name Executable name.
|
|
108
|
+
* @param {Function} [exec] Command runner, for tests.
|
|
93
109
|
* @returns {{version: string|null, present: boolean}} Probe result.
|
|
94
110
|
*/
|
|
95
|
-
function probe(name) {
|
|
111
|
+
export function probe(name, exec = execFileSync) {
|
|
96
112
|
try {
|
|
97
|
-
const out =
|
|
113
|
+
const out = exec(name, ["--version"], {
|
|
98
114
|
encoding: "utf8",
|
|
99
|
-
stdio: ["ignore", "pipe", "
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
116
|
});
|
|
101
117
|
return { present: true, version: extractVersion(out) };
|
|
102
|
-
} catch {
|
|
103
|
-
return { present: false, version: null };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.code === "ENOENT") return { present: false, version: null };
|
|
120
|
+
const output = `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
121
|
+
return { present: true, version: extractVersion(output) };
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -211,6 +229,46 @@ function runHook(hook, dryRun) {
|
|
|
211
229
|
/** Phases this runner can execute, in the order they must happen. */
|
|
212
230
|
const PHASES = ["toolchain", "secrets", "hook"];
|
|
213
231
|
|
|
232
|
+
/** Where a host project keeps the scripts its remote environment invokes. */
|
|
233
|
+
export const INSTALL_DIR = join("scripts", "lisa-remote-env");
|
|
234
|
+
|
|
235
|
+
/** Assets a host project needs on disk before any remote session can start. */
|
|
236
|
+
const INSTALLABLE = ["setup.sh", "session-start.sh"];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Copy this skill's assets into the host project that will run them.
|
|
240
|
+
*
|
|
241
|
+
* These live in the skill, but the paths that reference them are *repository*
|
|
242
|
+
* paths: a vendor's setup field says `bash scripts/lisa-remote-env/setup.sh`,
|
|
243
|
+
* and a session-start hook is only committed if it is a real file in the repo.
|
|
244
|
+
* Nothing put them there, so every reference resolved to a path that did not
|
|
245
|
+
* exist — the environment came up, ran a missing script, exited non-zero, and
|
|
246
|
+
* the session failed to start with no indication that a setup step was skipped.
|
|
247
|
+
*
|
|
248
|
+
* Copied rather than symlinked or generated: the file must survive in a fresh
|
|
249
|
+
* clone on a container that has never seen the plugin, which is the whole
|
|
250
|
+
* reason the entrypoint is thin enough to copy in the first place.
|
|
251
|
+
* @param {string} [cwd] Repository root.
|
|
252
|
+
* @returns {Array<{name: string, action: string}>} What was written.
|
|
253
|
+
*/
|
|
254
|
+
export function installAssets(cwd = process.cwd()) {
|
|
255
|
+
const destination = join(cwd, INSTALL_DIR);
|
|
256
|
+
mkdirSync(destination, { recursive: true, mode: 0o755 });
|
|
257
|
+
return INSTALLABLE.map(name => {
|
|
258
|
+
const source = resolve(HERE, "..", "assets", name);
|
|
259
|
+
if (!existsSync(source)) {
|
|
260
|
+
throw new Error(`asset ${name} is missing from this skill install`);
|
|
261
|
+
}
|
|
262
|
+
const target = join(destination, name);
|
|
263
|
+
const desired = readFileSync(source, "utf8");
|
|
264
|
+
const unchanged =
|
|
265
|
+
existsSync(target) && readFileSync(target, "utf8") === desired;
|
|
266
|
+
if (!unchanged) writeFileSync(target, desired, { mode: 0o755 });
|
|
267
|
+
chmodSync(target, 0o755);
|
|
268
|
+
return { name, action: unchanged ? "current" : "written" };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
214
272
|
/**
|
|
215
273
|
* The settings block that wires the session-start hook into a repository.
|
|
216
274
|
*
|
|
@@ -234,6 +292,43 @@ const SESSION_START_BLOCK = `{
|
|
|
234
292
|
}
|
|
235
293
|
}`;
|
|
236
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Pin which cloud environment this project's sessions use.
|
|
297
|
+
*
|
|
298
|
+
* `/remote-env` writes `remote.defaultEnvironmentId` into *user* settings, which
|
|
299
|
+
* is one value for the whole machine. That is wrong as soon as a developer has
|
|
300
|
+
* more than one project, because an environment's contents are project-shaped:
|
|
301
|
+
* its setup script is a repository-relative path and its bootstrap is scoped to
|
|
302
|
+
* that project's secrets. Pointing one project's session at another's
|
|
303
|
+
* environment runs a setup script that may not exist there, and a non-zero setup
|
|
304
|
+
* script means the session never starts.
|
|
305
|
+
*
|
|
306
|
+
* Written to `.claude/settings.local.json` for two reasons. It outranks user
|
|
307
|
+
* settings, so the per-project choice wins over the machine-wide default; and it
|
|
308
|
+
* is gitignored, which is correct because an environment belongs to one
|
|
309
|
+
* developer's account and is meaningless in someone else's checkout.
|
|
310
|
+
*
|
|
311
|
+
* Merged rather than replaced — that file commonly holds permission grants a
|
|
312
|
+
* developer has accumulated, and clobbering them to write one key would be a
|
|
313
|
+
* poor trade.
|
|
314
|
+
* @param {string} environmentId Cloud environment identifier.
|
|
315
|
+
* @param {string} [cwd] Repository root.
|
|
316
|
+
* @returns {string} The settings path written.
|
|
317
|
+
*/
|
|
318
|
+
export function pinEnvironment(environmentId, cwd = process.cwd()) {
|
|
319
|
+
const path = join(cwd, ".claude", "settings.local.json");
|
|
320
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
321
|
+
const existing = existsSync(path)
|
|
322
|
+
? JSON.parse(readFileSync(path, "utf8"))
|
|
323
|
+
: {};
|
|
324
|
+
const merged = {
|
|
325
|
+
...existing,
|
|
326
|
+
remote: { ...(existing.remote ?? {}), defaultEnvironmentId: environmentId },
|
|
327
|
+
};
|
|
328
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
329
|
+
return path;
|
|
330
|
+
}
|
|
331
|
+
|
|
237
332
|
/**
|
|
238
333
|
* Produce the configuration a human pastes to provision a Claude cloud surface.
|
|
239
334
|
*
|
|
@@ -389,6 +484,30 @@ async function main() {
|
|
|
389
484
|
.find(arg => arg.startsWith("--emit="))
|
|
390
485
|
?.slice("--emit=".length);
|
|
391
486
|
|
|
487
|
+
if (process.argv.includes("--install")) {
|
|
488
|
+
console.log(`Installing remote-environment scripts into ${INSTALL_DIR}/`);
|
|
489
|
+
for (const { name, action } of installAssets()) {
|
|
490
|
+
console.log(` ${action.padEnd(8)} ${join(INSTALL_DIR, name)}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const pin = process.argv
|
|
494
|
+
.find(arg => arg.startsWith("--pin-env="))
|
|
495
|
+
?.slice("--pin-env=".length);
|
|
496
|
+
if (pin) {
|
|
497
|
+
console.log(`\nPinned this project to environment ${pin}`);
|
|
498
|
+
console.log(` written ${pinEnvironment(pin)}`);
|
|
499
|
+
console.log(
|
|
500
|
+
" That file is gitignored and outranks the machine-wide default, so\n" +
|
|
501
|
+
" every project can name its own environment."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
console.log(
|
|
505
|
+
"\nCommit these. They are repository files by design — a container that " +
|
|
506
|
+
"has\njust cloned the repo has never seen the plugin they came from."
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
392
511
|
if (emit) {
|
|
393
512
|
if (emit !== "claude-web") {
|
|
394
513
|
throw new Error(
|
|
@@ -162,6 +162,18 @@ It reads the project's own install command from its lockfile and the bootstrap n
|
|
|
162
162
|
- **`gh` is not pre-installed.** If the project's flows shell out to it, add it to `remoteEnv.tools.install`, pinned and checksummed like anything else.
|
|
163
163
|
- **A proxied credential reads as the literal string `proxy-injected`.** Tools that authenticate through the GitHub proxy work; a script that reads the variable directly gets the placeholder. The read-back asserts this rather than leaving it to be discovered against a live service.
|
|
164
164
|
|
|
165
|
+
### One environment per project, pinned locally
|
|
166
|
+
|
|
167
|
+
An environment is **not** bound to a repository — the repository arrives per session, and one environment is technically reusable across all of them. Give each project its own anyway, because an environment's *contents* are project-shaped: its setup script is a repository-relative path and its bootstrap is scoped to that project's secrets. Pointing one project's session at another's environment runs a setup script that may not exist there, and a setup script that exits non-zero means the session never starts.
|
|
168
|
+
|
|
169
|
+
`/remote-env` writes `remote.defaultEnvironmentId` into **user** settings, which is one value for the whole machine — wrong as soon as a developer has two projects. Pin it per project instead:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
node scripts/setup-remote-env.mjs --install --pin-env=<environment-id>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
That writes `.claude/settings.local.json`, which outranks user settings and is gitignored — correct on both counts, since an environment belongs to one developer's account and is meaningless in someone else's checkout. Existing keys in that file are merged, not replaced; it commonly holds permission grants worth keeping.
|
|
176
|
+
|
|
165
177
|
**Only the bootstrap belongs in the environment-variable box.** Values there are stored as plain text and are readable by anyone who uses the environment — on an organization-shared environment, that is every member of the organization. Everything else is materialized by the session-start hook. There is no dedicated secrets store on this surface, and personal versus shared environments cannot be told apart programmatically, so this one is a rule the operator upholds rather than something the tooling can enforce.
|
|
166
178
|
|
|
167
179
|
## Verification is tier-independent
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
mkdirSync,
|
|
36
36
|
readFileSync,
|
|
37
37
|
rmSync,
|
|
38
|
+
writeFileSync,
|
|
38
39
|
} from "node:fs";
|
|
39
40
|
import { dirname, join, resolve } from "node:path";
|
|
40
41
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -89,18 +90,35 @@ export function readRemoteEnvConfig(cwd = process.cwd()) {
|
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* Probe what a tool reports for its version, treating absence as not present.
|
|
93
|
+
*
|
|
94
|
+
* "Not installed" and "dislikes `--version`" are different answers, and only a
|
|
95
|
+
* failure to *spawn* means the first. Info-ZIP's `unzip` — the build shipped by
|
|
96
|
+
* both macOS and Ubuntu — parses `--version` one letter at a time, warns that
|
|
97
|
+
* `-n` and `-o` conflict, and exits 10. Reading that as absence made `require`
|
|
98
|
+
* fail on a machine that had the tool, which is precisely the false alarm the
|
|
99
|
+
* check exists to avoid raising.
|
|
100
|
+
*
|
|
101
|
+
* Version text is still recovered from a failed invocation where there is any,
|
|
102
|
+
* because a tool that refuses the flag often prints its banner anyway — which
|
|
103
|
+
* is how `unzip` still reports 6.00 despite exiting non-zero.
|
|
104
|
+
*
|
|
105
|
+
* The runner is injectable so both branches can be exercised without depending
|
|
106
|
+
* on which quirky binaries happen to exist on the machine running the tests.
|
|
92
107
|
* @param {string} name Executable name.
|
|
108
|
+
* @param {Function} [exec] Command runner, for tests.
|
|
93
109
|
* @returns {{version: string|null, present: boolean}} Probe result.
|
|
94
110
|
*/
|
|
95
|
-
function probe(name) {
|
|
111
|
+
export function probe(name, exec = execFileSync) {
|
|
96
112
|
try {
|
|
97
|
-
const out =
|
|
113
|
+
const out = exec(name, ["--version"], {
|
|
98
114
|
encoding: "utf8",
|
|
99
|
-
stdio: ["ignore", "pipe", "
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
116
|
});
|
|
101
117
|
return { present: true, version: extractVersion(out) };
|
|
102
|
-
} catch {
|
|
103
|
-
return { present: false, version: null };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.code === "ENOENT") return { present: false, version: null };
|
|
120
|
+
const output = `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
121
|
+
return { present: true, version: extractVersion(output) };
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -211,6 +229,46 @@ function runHook(hook, dryRun) {
|
|
|
211
229
|
/** Phases this runner can execute, in the order they must happen. */
|
|
212
230
|
const PHASES = ["toolchain", "secrets", "hook"];
|
|
213
231
|
|
|
232
|
+
/** Where a host project keeps the scripts its remote environment invokes. */
|
|
233
|
+
export const INSTALL_DIR = join("scripts", "lisa-remote-env");
|
|
234
|
+
|
|
235
|
+
/** Assets a host project needs on disk before any remote session can start. */
|
|
236
|
+
const INSTALLABLE = ["setup.sh", "session-start.sh"];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Copy this skill's assets into the host project that will run them.
|
|
240
|
+
*
|
|
241
|
+
* These live in the skill, but the paths that reference them are *repository*
|
|
242
|
+
* paths: a vendor's setup field says `bash scripts/lisa-remote-env/setup.sh`,
|
|
243
|
+
* and a session-start hook is only committed if it is a real file in the repo.
|
|
244
|
+
* Nothing put them there, so every reference resolved to a path that did not
|
|
245
|
+
* exist — the environment came up, ran a missing script, exited non-zero, and
|
|
246
|
+
* the session failed to start with no indication that a setup step was skipped.
|
|
247
|
+
*
|
|
248
|
+
* Copied rather than symlinked or generated: the file must survive in a fresh
|
|
249
|
+
* clone on a container that has never seen the plugin, which is the whole
|
|
250
|
+
* reason the entrypoint is thin enough to copy in the first place.
|
|
251
|
+
* @param {string} [cwd] Repository root.
|
|
252
|
+
* @returns {Array<{name: string, action: string}>} What was written.
|
|
253
|
+
*/
|
|
254
|
+
export function installAssets(cwd = process.cwd()) {
|
|
255
|
+
const destination = join(cwd, INSTALL_DIR);
|
|
256
|
+
mkdirSync(destination, { recursive: true, mode: 0o755 });
|
|
257
|
+
return INSTALLABLE.map(name => {
|
|
258
|
+
const source = resolve(HERE, "..", "assets", name);
|
|
259
|
+
if (!existsSync(source)) {
|
|
260
|
+
throw new Error(`asset ${name} is missing from this skill install`);
|
|
261
|
+
}
|
|
262
|
+
const target = join(destination, name);
|
|
263
|
+
const desired = readFileSync(source, "utf8");
|
|
264
|
+
const unchanged =
|
|
265
|
+
existsSync(target) && readFileSync(target, "utf8") === desired;
|
|
266
|
+
if (!unchanged) writeFileSync(target, desired, { mode: 0o755 });
|
|
267
|
+
chmodSync(target, 0o755);
|
|
268
|
+
return { name, action: unchanged ? "current" : "written" };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
214
272
|
/**
|
|
215
273
|
* The settings block that wires the session-start hook into a repository.
|
|
216
274
|
*
|
|
@@ -234,6 +292,43 @@ const SESSION_START_BLOCK = `{
|
|
|
234
292
|
}
|
|
235
293
|
}`;
|
|
236
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Pin which cloud environment this project's sessions use.
|
|
297
|
+
*
|
|
298
|
+
* `/remote-env` writes `remote.defaultEnvironmentId` into *user* settings, which
|
|
299
|
+
* is one value for the whole machine. That is wrong as soon as a developer has
|
|
300
|
+
* more than one project, because an environment's contents are project-shaped:
|
|
301
|
+
* its setup script is a repository-relative path and its bootstrap is scoped to
|
|
302
|
+
* that project's secrets. Pointing one project's session at another's
|
|
303
|
+
* environment runs a setup script that may not exist there, and a non-zero setup
|
|
304
|
+
* script means the session never starts.
|
|
305
|
+
*
|
|
306
|
+
* Written to `.claude/settings.local.json` for two reasons. It outranks user
|
|
307
|
+
* settings, so the per-project choice wins over the machine-wide default; and it
|
|
308
|
+
* is gitignored, which is correct because an environment belongs to one
|
|
309
|
+
* developer's account and is meaningless in someone else's checkout.
|
|
310
|
+
*
|
|
311
|
+
* Merged rather than replaced — that file commonly holds permission grants a
|
|
312
|
+
* developer has accumulated, and clobbering them to write one key would be a
|
|
313
|
+
* poor trade.
|
|
314
|
+
* @param {string} environmentId Cloud environment identifier.
|
|
315
|
+
* @param {string} [cwd] Repository root.
|
|
316
|
+
* @returns {string} The settings path written.
|
|
317
|
+
*/
|
|
318
|
+
export function pinEnvironment(environmentId, cwd = process.cwd()) {
|
|
319
|
+
const path = join(cwd, ".claude", "settings.local.json");
|
|
320
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
321
|
+
const existing = existsSync(path)
|
|
322
|
+
? JSON.parse(readFileSync(path, "utf8"))
|
|
323
|
+
: {};
|
|
324
|
+
const merged = {
|
|
325
|
+
...existing,
|
|
326
|
+
remote: { ...(existing.remote ?? {}), defaultEnvironmentId: environmentId },
|
|
327
|
+
};
|
|
328
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
329
|
+
return path;
|
|
330
|
+
}
|
|
331
|
+
|
|
237
332
|
/**
|
|
238
333
|
* Produce the configuration a human pastes to provision a Claude cloud surface.
|
|
239
334
|
*
|
|
@@ -389,6 +484,30 @@ async function main() {
|
|
|
389
484
|
.find(arg => arg.startsWith("--emit="))
|
|
390
485
|
?.slice("--emit=".length);
|
|
391
486
|
|
|
487
|
+
if (process.argv.includes("--install")) {
|
|
488
|
+
console.log(`Installing remote-environment scripts into ${INSTALL_DIR}/`);
|
|
489
|
+
for (const { name, action } of installAssets()) {
|
|
490
|
+
console.log(` ${action.padEnd(8)} ${join(INSTALL_DIR, name)}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const pin = process.argv
|
|
494
|
+
.find(arg => arg.startsWith("--pin-env="))
|
|
495
|
+
?.slice("--pin-env=".length);
|
|
496
|
+
if (pin) {
|
|
497
|
+
console.log(`\nPinned this project to environment ${pin}`);
|
|
498
|
+
console.log(` written ${pinEnvironment(pin)}`);
|
|
499
|
+
console.log(
|
|
500
|
+
" That file is gitignored and outranks the machine-wide default, so\n" +
|
|
501
|
+
" every project can name its own environment."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
console.log(
|
|
505
|
+
"\nCommit these. They are repository files by design — a container that " +
|
|
506
|
+
"has\njust cloned the repo has never seen the plugin they came from."
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
392
511
|
if (emit) {
|
|
393
512
|
if (emit !== "claude-web") {
|
|
394
513
|
throw new Error(
|
|
@@ -162,6 +162,18 @@ It reads the project's own install command from its lockfile and the bootstrap n
|
|
|
162
162
|
- **`gh` is not pre-installed.** If the project's flows shell out to it, add it to `remoteEnv.tools.install`, pinned and checksummed like anything else.
|
|
163
163
|
- **A proxied credential reads as the literal string `proxy-injected`.** Tools that authenticate through the GitHub proxy work; a script that reads the variable directly gets the placeholder. The read-back asserts this rather than leaving it to be discovered against a live service.
|
|
164
164
|
|
|
165
|
+
### One environment per project, pinned locally
|
|
166
|
+
|
|
167
|
+
An environment is **not** bound to a repository — the repository arrives per session, and one environment is technically reusable across all of them. Give each project its own anyway, because an environment's *contents* are project-shaped: its setup script is a repository-relative path and its bootstrap is scoped to that project's secrets. Pointing one project's session at another's environment runs a setup script that may not exist there, and a setup script that exits non-zero means the session never starts.
|
|
168
|
+
|
|
169
|
+
`/remote-env` writes `remote.defaultEnvironmentId` into **user** settings, which is one value for the whole machine — wrong as soon as a developer has two projects. Pin it per project instead:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
node scripts/setup-remote-env.mjs --install --pin-env=<environment-id>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
That writes `.claude/settings.local.json`, which outranks user settings and is gitignored — correct on both counts, since an environment belongs to one developer's account and is meaningless in someone else's checkout. Existing keys in that file are merged, not replaced; it commonly holds permission grants worth keeping.
|
|
176
|
+
|
|
165
177
|
**Only the bootstrap belongs in the environment-variable box.** Values there are stored as plain text and are readable by anyone who uses the environment — on an organization-shared environment, that is every member of the organization. Everything else is materialized by the session-start hook. There is no dedicated secrets store on this surface, and personal versus shared environments cannot be told apart programmatically, so this one is a rule the operator upholds rather than something the tooling can enforce.
|
|
166
178
|
|
|
167
179
|
## Verification is tier-independent
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
mkdirSync,
|
|
36
36
|
readFileSync,
|
|
37
37
|
rmSync,
|
|
38
|
+
writeFileSync,
|
|
38
39
|
} from "node:fs";
|
|
39
40
|
import { dirname, join, resolve } from "node:path";
|
|
40
41
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -89,18 +90,35 @@ export function readRemoteEnvConfig(cwd = process.cwd()) {
|
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* Probe what a tool reports for its version, treating absence as not present.
|
|
93
|
+
*
|
|
94
|
+
* "Not installed" and "dislikes `--version`" are different answers, and only a
|
|
95
|
+
* failure to *spawn* means the first. Info-ZIP's `unzip` — the build shipped by
|
|
96
|
+
* both macOS and Ubuntu — parses `--version` one letter at a time, warns that
|
|
97
|
+
* `-n` and `-o` conflict, and exits 10. Reading that as absence made `require`
|
|
98
|
+
* fail on a machine that had the tool, which is precisely the false alarm the
|
|
99
|
+
* check exists to avoid raising.
|
|
100
|
+
*
|
|
101
|
+
* Version text is still recovered from a failed invocation where there is any,
|
|
102
|
+
* because a tool that refuses the flag often prints its banner anyway — which
|
|
103
|
+
* is how `unzip` still reports 6.00 despite exiting non-zero.
|
|
104
|
+
*
|
|
105
|
+
* The runner is injectable so both branches can be exercised without depending
|
|
106
|
+
* on which quirky binaries happen to exist on the machine running the tests.
|
|
92
107
|
* @param {string} name Executable name.
|
|
108
|
+
* @param {Function} [exec] Command runner, for tests.
|
|
93
109
|
* @returns {{version: string|null, present: boolean}} Probe result.
|
|
94
110
|
*/
|
|
95
|
-
function probe(name) {
|
|
111
|
+
export function probe(name, exec = execFileSync) {
|
|
96
112
|
try {
|
|
97
|
-
const out =
|
|
113
|
+
const out = exec(name, ["--version"], {
|
|
98
114
|
encoding: "utf8",
|
|
99
|
-
stdio: ["ignore", "pipe", "
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
116
|
});
|
|
101
117
|
return { present: true, version: extractVersion(out) };
|
|
102
|
-
} catch {
|
|
103
|
-
return { present: false, version: null };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.code === "ENOENT") return { present: false, version: null };
|
|
120
|
+
const output = `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
121
|
+
return { present: true, version: extractVersion(output) };
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -211,6 +229,46 @@ function runHook(hook, dryRun) {
|
|
|
211
229
|
/** Phases this runner can execute, in the order they must happen. */
|
|
212
230
|
const PHASES = ["toolchain", "secrets", "hook"];
|
|
213
231
|
|
|
232
|
+
/** Where a host project keeps the scripts its remote environment invokes. */
|
|
233
|
+
export const INSTALL_DIR = join("scripts", "lisa-remote-env");
|
|
234
|
+
|
|
235
|
+
/** Assets a host project needs on disk before any remote session can start. */
|
|
236
|
+
const INSTALLABLE = ["setup.sh", "session-start.sh"];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Copy this skill's assets into the host project that will run them.
|
|
240
|
+
*
|
|
241
|
+
* These live in the skill, but the paths that reference them are *repository*
|
|
242
|
+
* paths: a vendor's setup field says `bash scripts/lisa-remote-env/setup.sh`,
|
|
243
|
+
* and a session-start hook is only committed if it is a real file in the repo.
|
|
244
|
+
* Nothing put them there, so every reference resolved to a path that did not
|
|
245
|
+
* exist — the environment came up, ran a missing script, exited non-zero, and
|
|
246
|
+
* the session failed to start with no indication that a setup step was skipped.
|
|
247
|
+
*
|
|
248
|
+
* Copied rather than symlinked or generated: the file must survive in a fresh
|
|
249
|
+
* clone on a container that has never seen the plugin, which is the whole
|
|
250
|
+
* reason the entrypoint is thin enough to copy in the first place.
|
|
251
|
+
* @param {string} [cwd] Repository root.
|
|
252
|
+
* @returns {Array<{name: string, action: string}>} What was written.
|
|
253
|
+
*/
|
|
254
|
+
export function installAssets(cwd = process.cwd()) {
|
|
255
|
+
const destination = join(cwd, INSTALL_DIR);
|
|
256
|
+
mkdirSync(destination, { recursive: true, mode: 0o755 });
|
|
257
|
+
return INSTALLABLE.map(name => {
|
|
258
|
+
const source = resolve(HERE, "..", "assets", name);
|
|
259
|
+
if (!existsSync(source)) {
|
|
260
|
+
throw new Error(`asset ${name} is missing from this skill install`);
|
|
261
|
+
}
|
|
262
|
+
const target = join(destination, name);
|
|
263
|
+
const desired = readFileSync(source, "utf8");
|
|
264
|
+
const unchanged =
|
|
265
|
+
existsSync(target) && readFileSync(target, "utf8") === desired;
|
|
266
|
+
if (!unchanged) writeFileSync(target, desired, { mode: 0o755 });
|
|
267
|
+
chmodSync(target, 0o755);
|
|
268
|
+
return { name, action: unchanged ? "current" : "written" };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
214
272
|
/**
|
|
215
273
|
* The settings block that wires the session-start hook into a repository.
|
|
216
274
|
*
|
|
@@ -234,6 +292,43 @@ const SESSION_START_BLOCK = `{
|
|
|
234
292
|
}
|
|
235
293
|
}`;
|
|
236
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Pin which cloud environment this project's sessions use.
|
|
297
|
+
*
|
|
298
|
+
* `/remote-env` writes `remote.defaultEnvironmentId` into *user* settings, which
|
|
299
|
+
* is one value for the whole machine. That is wrong as soon as a developer has
|
|
300
|
+
* more than one project, because an environment's contents are project-shaped:
|
|
301
|
+
* its setup script is a repository-relative path and its bootstrap is scoped to
|
|
302
|
+
* that project's secrets. Pointing one project's session at another's
|
|
303
|
+
* environment runs a setup script that may not exist there, and a non-zero setup
|
|
304
|
+
* script means the session never starts.
|
|
305
|
+
*
|
|
306
|
+
* Written to `.claude/settings.local.json` for two reasons. It outranks user
|
|
307
|
+
* settings, so the per-project choice wins over the machine-wide default; and it
|
|
308
|
+
* is gitignored, which is correct because an environment belongs to one
|
|
309
|
+
* developer's account and is meaningless in someone else's checkout.
|
|
310
|
+
*
|
|
311
|
+
* Merged rather than replaced — that file commonly holds permission grants a
|
|
312
|
+
* developer has accumulated, and clobbering them to write one key would be a
|
|
313
|
+
* poor trade.
|
|
314
|
+
* @param {string} environmentId Cloud environment identifier.
|
|
315
|
+
* @param {string} [cwd] Repository root.
|
|
316
|
+
* @returns {string} The settings path written.
|
|
317
|
+
*/
|
|
318
|
+
export function pinEnvironment(environmentId, cwd = process.cwd()) {
|
|
319
|
+
const path = join(cwd, ".claude", "settings.local.json");
|
|
320
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
321
|
+
const existing = existsSync(path)
|
|
322
|
+
? JSON.parse(readFileSync(path, "utf8"))
|
|
323
|
+
: {};
|
|
324
|
+
const merged = {
|
|
325
|
+
...existing,
|
|
326
|
+
remote: { ...(existing.remote ?? {}), defaultEnvironmentId: environmentId },
|
|
327
|
+
};
|
|
328
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
329
|
+
return path;
|
|
330
|
+
}
|
|
331
|
+
|
|
237
332
|
/**
|
|
238
333
|
* Produce the configuration a human pastes to provision a Claude cloud surface.
|
|
239
334
|
*
|
|
@@ -389,6 +484,30 @@ async function main() {
|
|
|
389
484
|
.find(arg => arg.startsWith("--emit="))
|
|
390
485
|
?.slice("--emit=".length);
|
|
391
486
|
|
|
487
|
+
if (process.argv.includes("--install")) {
|
|
488
|
+
console.log(`Installing remote-environment scripts into ${INSTALL_DIR}/`);
|
|
489
|
+
for (const { name, action } of installAssets()) {
|
|
490
|
+
console.log(` ${action.padEnd(8)} ${join(INSTALL_DIR, name)}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const pin = process.argv
|
|
494
|
+
.find(arg => arg.startsWith("--pin-env="))
|
|
495
|
+
?.slice("--pin-env=".length);
|
|
496
|
+
if (pin) {
|
|
497
|
+
console.log(`\nPinned this project to environment ${pin}`);
|
|
498
|
+
console.log(` written ${pinEnvironment(pin)}`);
|
|
499
|
+
console.log(
|
|
500
|
+
" That file is gitignored and outranks the machine-wide default, so\n" +
|
|
501
|
+
" every project can name its own environment."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
console.log(
|
|
505
|
+
"\nCommit these. They are repository files by design — a container that " +
|
|
506
|
+
"has\njust cloned the repo has never seen the plugin they came from."
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
392
511
|
if (emit) {
|
|
393
512
|
if (emit !== "claude-web") {
|
|
394
513
|
throw new Error(
|