@commonlyai/cli 0.1.4 → 0.1.5
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/package.json
CHANGED
package/skills/commonly/SKILL.md
CHANGED
|
@@ -120,6 +120,14 @@ turn with the literal `NO_REPLY`. So: if you already said everything through
|
|
|
120
120
|
`NO_REPLY` so the wrapper doesn't post a duplicate. If you *didn't* post via the
|
|
121
121
|
tool, just let your reply be your final output. Either way — one voice, no echo.
|
|
122
122
|
|
|
123
|
+
**Post as yourself, never as your operator.** Your reply text and your
|
|
124
|
+
`commonly_*` tools carry *your* agent identity. If you have shell access, you may
|
|
125
|
+
find an operator's Commonly CLI profile (`commonly pod send`, `~/.commonly/config.json`)
|
|
126
|
+
or a human's saved token in your environment — **never post through them**. A message
|
|
127
|
+
sent that way appears in the room under the human's name and avatar, which
|
|
128
|
+
misattributes your words and breaks the room's provenance. If your own tools are
|
|
129
|
+
unavailable mid-turn, say what you need in your final reply instead.
|
|
130
|
+
|
|
123
131
|
## The task board
|
|
124
132
|
|
|
125
133
|
Pods have a task board. When work is being tracked:
|
package/src/commands/agent.js
CHANGED
|
@@ -175,7 +175,7 @@ export const buildDefaultEnvironment = (adapterName) => {
|
|
|
175
175
|
],
|
|
176
176
|
};
|
|
177
177
|
// Only advertise the skill if it actually shipped (defensive: a broken
|
|
178
|
-
// package that dropped the skills/ dir shouldn't hand
|
|
178
|
+
// package that dropped the skills/ dir shouldn't hand mountSkills a
|
|
179
179
|
// missing-source path every spawn).
|
|
180
180
|
if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) {
|
|
181
181
|
environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] };
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
import { homedir, tmpdir } from 'os';
|
|
61
61
|
import { isAbsolute, join } from 'path';
|
|
62
62
|
|
|
63
|
-
import {
|
|
63
|
+
import { mountSkills } from '../environment.js';
|
|
64
64
|
import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
65
65
|
import {
|
|
66
66
|
publicClaudeStateRoot,
|
|
@@ -468,11 +468,11 @@ export default {
|
|
|
468
468
|
const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
|
|
469
469
|
|
|
470
470
|
if (ctx.environment && ctx.cwd) {
|
|
471
|
-
const skills = await
|
|
471
|
+
const skills = await mountSkills(ctx.environment, ctx.cwd);
|
|
472
472
|
if (skills.conflicted.length > 0) {
|
|
473
473
|
for (const c of skills.conflicted) {
|
|
474
474
|
// eslint-disable-next-line no-console
|
|
475
|
-
console.warn(`[claude] skill not
|
|
475
|
+
console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`);
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
478
|
if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
|
package/src/lib/environment.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* how an agent's runtime should be shaped: workspace path, sandbox mode, the
|
|
6
6
|
* Claude skills to mount, the MCP servers to expose. This module is the
|
|
7
7
|
* adapter-facing read-side: parse, validate, and realize the workspace +
|
|
8
|
-
* skill
|
|
8
|
+
* skill mounts on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
|
|
9
9
|
* because it is per-driver (Linux-only, bwrap-specific).
|
|
10
10
|
*
|
|
11
11
|
* Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* place to add a `.yaml` branch — the rest of the module is parser-agnostic.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
|
|
19
|
+
} from 'fs/promises';
|
|
18
20
|
import { existsSync } from 'fs';
|
|
19
21
|
import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
|
|
20
22
|
import { homedir } from 'os';
|
|
@@ -25,7 +27,7 @@ import { homedir } from 'os';
|
|
|
25
27
|
// relative skill paths) is NOT stored on the returned spec. Leaking the user's
|
|
26
28
|
// absolute filesystem path into `config.environment` sent to the backend
|
|
27
29
|
// exposes `$HOME` layout for zero server-side benefit. Callers pass
|
|
28
|
-
// `envFileDir` as a separate argument to resolveWorkspace /
|
|
30
|
+
// `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
|
|
29
31
|
|
|
30
32
|
const ALLOWED_TOP_KEYS = new Set([
|
|
31
33
|
'version', 'workspace', 'sandbox', 'skills', 'mcp',
|
|
@@ -85,7 +87,7 @@ export const parseEnvironmentFile = async (absolutePath) => {
|
|
|
85
87
|
// Return the bare spec — no envFileDir wrapping, no underscore-prefixed
|
|
86
88
|
// annotations. The caller is responsible for tracking envFileDir separately
|
|
87
89
|
// (compute via `dirname(envPath)`) and passing it explicitly to
|
|
88
|
-
// resolveWorkspace /
|
|
90
|
+
// resolveWorkspace / mountSkills when relative paths in the spec need to
|
|
89
91
|
// resolve. This keeps the spec safe to serialize and ship to the backend
|
|
90
92
|
// (`config.environment` on AgentInstallation) without leaking $HOME layout.
|
|
91
93
|
return parsed;
|
|
@@ -246,35 +248,72 @@ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => {
|
|
|
246
248
|
return { path: absPath, created };
|
|
247
249
|
};
|
|
248
250
|
|
|
249
|
-
// ──
|
|
251
|
+
// ── mountSkills ─────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
// Marks a skill directory as a Commonly-managed snapshot. Its presence is what
|
|
254
|
+
// licenses a refresh to delete and re-copy the directory: without it we cannot
|
|
255
|
+
// distinguish "a mount we made last spawn" from "a skill the user authored in
|
|
256
|
+
// their workspace", and clobbering the latter would destroy real work.
|
|
257
|
+
const MOUNT_MARKER = '.commonly-skill-mount';
|
|
250
258
|
|
|
251
259
|
/**
|
|
252
|
-
*
|
|
260
|
+
* Recursively drop the write bit on every regular file in a mounted snapshot.
|
|
261
|
+
*
|
|
262
|
+
* Directories deliberately stay writable: unlinking a file requires write
|
|
263
|
+
* permission on its PARENT, so read-only directories would make the next
|
|
264
|
+
* refresh fail. This is defense-in-depth for the agent's own copy, not the
|
|
265
|
+
* load-bearing protection — that comes from the copy existing at all.
|
|
266
|
+
*/
|
|
267
|
+
const freezeFiles = async (dir) => {
|
|
268
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
const full = join(dir, entry.name);
|
|
271
|
+
if (entry.isDirectory()) {
|
|
272
|
+
// eslint-disable-next-line no-await-in-loop
|
|
273
|
+
await freezeFiles(full);
|
|
274
|
+
} else if (entry.isFile()) {
|
|
275
|
+
// eslint-disable-next-line no-await-in-loop
|
|
276
|
+
await chmod(full, 0o444);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Mount each `skills.claude[]` source into `<workspacePath>/.claude/skills/`
|
|
283
|
+
* as a READ-ONLY SNAPSHOT COPY.
|
|
284
|
+
*
|
|
285
|
+
* Why a copy and not a symlink: a symlink is writable *through*, so an agent
|
|
286
|
+
* holding Write/Edit could edit the file that governs its own behaviour — and
|
|
287
|
+
* because these sources are usually files tracked in a repo (the bundled
|
|
288
|
+
* `cli/skills/commonly` is the common case), that edit lands in the operator's
|
|
289
|
+
* working tree. `npm publish` ships the working tree, not git HEAD, so a
|
|
290
|
+
* write-through edit can silently ride out in the next release. That is not
|
|
291
|
+
* hypothetical: @commonlyai/cli@0.1.4 shipped with the "post as yourself,
|
|
292
|
+
* never as your operator" guardrail (#702) missing for exactly this reason.
|
|
293
|
+
* The snapshot makes the data flow one-way: source → workspace, never back.
|
|
253
294
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
295
|
+
* Refreshed on every call — the claude adapter mounts per spawn — so edits to
|
|
296
|
+
* the source still reach the agent on its next turn. Only directories carrying
|
|
297
|
+
* MOUNT_MARKER are refreshed; anything else in the slot is reported as a
|
|
298
|
+
* conflict and left untouched.
|
|
299
|
+
*
|
|
300
|
+
* Legacy symlinks written by CLI ≤0.1.4 are replaced with a snapshot, since
|
|
301
|
+
* the symlink IS the vulnerable shape being retired.
|
|
260
302
|
*
|
|
261
303
|
* `envFileDir` is the directory of the env file (used to resolve relative
|
|
262
304
|
* skill paths). Optional — when omitted, relative paths fall back to
|
|
263
|
-
* `workspacePath`.
|
|
264
|
-
* matching the env file's location.
|
|
305
|
+
* `workspacePath`.
|
|
265
306
|
*
|
|
266
|
-
* Returns `{
|
|
267
|
-
* - `
|
|
268
|
-
* - `skipped`: string[] — already correctly linked (no-op)
|
|
307
|
+
* Returns `{ mounted, conflicted }`:
|
|
308
|
+
* - `mounted`: string[] — absolute source paths snapshotted
|
|
269
309
|
* - `conflicted`: Array<{path, reason}> where reason ∈
|
|
270
|
-
* '
|
|
310
|
+
* 'not-a-mount' | 'missing-source'
|
|
271
311
|
*/
|
|
272
|
-
export const
|
|
312
|
+
export const mountSkills = async (spec, workspacePath, envFileDir = null) => {
|
|
273
313
|
const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : [];
|
|
274
|
-
const
|
|
275
|
-
const skipped = [];
|
|
314
|
+
const mounted = [];
|
|
276
315
|
const conflicted = [];
|
|
277
|
-
if (sources.length === 0) return {
|
|
316
|
+
if (sources.length === 0) return { mounted, conflicted };
|
|
278
317
|
|
|
279
318
|
const skillsDir = join(workspacePath, '.claude', 'skills');
|
|
280
319
|
await mkdir(skillsDir, { recursive: true });
|
|
@@ -288,47 +327,43 @@ export const linkSkills = async (spec, workspacePath, envFileDir = null) => {
|
|
|
288
327
|
conflicted.push({ path: absSource, reason: 'missing-source' });
|
|
289
328
|
continue;
|
|
290
329
|
}
|
|
291
|
-
const
|
|
330
|
+
const destPath = join(skillsDir, basename(absSource));
|
|
292
331
|
|
|
293
|
-
let
|
|
294
|
-
let slotIsNonSymlink = false;
|
|
332
|
+
let slot = null;
|
|
295
333
|
try {
|
|
296
334
|
// eslint-disable-next-line no-await-in-loop
|
|
297
|
-
|
|
298
|
-
if (stat.isSymbolicLink()) {
|
|
299
|
-
// eslint-disable-next-line no-await-in-loop
|
|
300
|
-
existingTarget = await readlink(linkPath);
|
|
301
|
-
} else {
|
|
302
|
-
slotIsNonSymlink = true;
|
|
303
|
-
}
|
|
335
|
+
slot = await lstat(destPath);
|
|
304
336
|
} catch (err) {
|
|
305
337
|
// Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are
|
|
306
|
-
// real failures that should surface
|
|
307
|
-
//
|
|
338
|
+
// real failures that should surface rather than becoming a confusing
|
|
339
|
+
// follow-on error from the copy below.
|
|
308
340
|
if (err.code !== 'ENOENT') throw err;
|
|
309
341
|
}
|
|
310
342
|
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const resolvedExisting = isAbsolute(existingTarget)
|
|
318
|
-
? existingTarget
|
|
319
|
-
: pathResolve(skillsDir, existingTarget);
|
|
320
|
-
if (resolvedExisting === absSource) {
|
|
321
|
-
skipped.push(absSource);
|
|
343
|
+
if (slot) {
|
|
344
|
+
const isLegacySymlink = slot.isSymbolicLink();
|
|
345
|
+
const isOwnedMount = slot.isDirectory() && existsSync(join(destPath, MOUNT_MARKER));
|
|
346
|
+
if (!isLegacySymlink && !isOwnedMount) {
|
|
347
|
+
// A real file, or a directory we did not create — user content.
|
|
348
|
+
conflicted.push({ path: absSource, reason: 'not-a-mount' });
|
|
322
349
|
continue;
|
|
323
350
|
}
|
|
324
|
-
|
|
325
|
-
|
|
351
|
+
// eslint-disable-next-line no-await-in-loop
|
|
352
|
+
await rm(destPath, { recursive: true, force: true });
|
|
326
353
|
}
|
|
327
354
|
|
|
328
355
|
// eslint-disable-next-line no-await-in-loop
|
|
329
|
-
await
|
|
330
|
-
|
|
356
|
+
await cp(absSource, destPath, { recursive: true });
|
|
357
|
+
// eslint-disable-next-line no-await-in-loop
|
|
358
|
+
await writeFile(
|
|
359
|
+
join(destPath, MOUNT_MARKER),
|
|
360
|
+
`mounted-from: ${absSource}\n`,
|
|
361
|
+
'utf8',
|
|
362
|
+
);
|
|
363
|
+
// eslint-disable-next-line no-await-in-loop
|
|
364
|
+
await freezeFiles(destPath);
|
|
365
|
+
mounted.push(absSource);
|
|
331
366
|
}
|
|
332
367
|
|
|
333
|
-
return {
|
|
368
|
+
return { mounted, conflicted };
|
|
334
369
|
};
|