@commonlyai/cli 0.1.4 → 0.1.6
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 +1 -1
- package/skills/commonly/SKILL.md +8 -0
- package/src/commands/agent.js +35 -12
- package/src/lib/adapters/claude.js +3 -3
- package/src/lib/environment.js +85 -50
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] };
|
|
@@ -502,9 +502,10 @@ export const performRun = ({
|
|
|
502
502
|
// did, its final CLI text is a narration/log — echoing it would duplicate
|
|
503
503
|
// the message and re-fire any @mention. This is the wrapper-side guarantee
|
|
504
504
|
// that a deliberate multi-post ("on it…" then "…done") is never doubled,
|
|
505
|
-
// without relying on the agent to emit NO_REPLY.
|
|
506
|
-
//
|
|
507
|
-
//
|
|
505
|
+
// without relying on the agent to emit NO_REPLY. Against a server that
|
|
506
|
+
// stamps `self` this is exact; against an older one it degrades to the
|
|
507
|
+
// legacy any-bot test, where a concurrent post by another agent can
|
|
508
|
+
// suppress a genuine reply (#757).
|
|
508
509
|
const snapshotMessages = async () => {
|
|
509
510
|
try {
|
|
510
511
|
const { messages = [] } = await client.get(
|
|
@@ -551,18 +552,33 @@ export const performRun = ({
|
|
|
551
552
|
// reply via its output) the wrapper delivers the text as before.
|
|
552
553
|
const replyText = (result.text || '').trim();
|
|
553
554
|
let agentPostedItself = false;
|
|
555
|
+
let suppressedBy = null;
|
|
554
556
|
if (preSpawnIds) {
|
|
555
557
|
const postSpawn = await snapshotMessages();
|
|
556
558
|
if (postSpawn) {
|
|
559
|
+
// The server stamps each message with `self` when it knows who is
|
|
560
|
+
// asking. Trust that over any local guess: the wrapper cannot derive
|
|
561
|
+
// its own bot username reliably (the server applies LEGACY_AGENT_MAP
|
|
562
|
+
// and an owner-scoped instanceId), and a wrong guess double-posts.
|
|
563
|
+
const serverKnowsSelf = postSpawn.some((m) => typeof m.self === 'boolean');
|
|
557
564
|
for (const m of postSpawn) {
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
if (!
|
|
565
|
+
if (preSpawnIds.has(String(m._id || m.id))) continue;
|
|
566
|
+
// A new human-authored message must NEVER suppress the echo: it is
|
|
567
|
+
// either a human typing mid-turn, or the agent misusing an operator
|
|
568
|
+
// CLI profile / human token (the 2026-07-22 as-operator attribution
|
|
569
|
+
// incident) — in both cases the wrapper still delivers the reply
|
|
570
|
+
// under the agent's own identity.
|
|
571
|
+
if (!m.isBot) continue;
|
|
572
|
+
// With `self`, only THIS agent's own post suppresses. Without it (an
|
|
573
|
+
// older server), fall back to the legacy any-bot test — which drops
|
|
574
|
+
// this agent's reply whenever another agent answers first (#757).
|
|
575
|
+
if (serverKnowsSelf ? m.self === true : true) {
|
|
565
576
|
agentPostedItself = true;
|
|
577
|
+
suppressedBy = {
|
|
578
|
+
id: String(m._id || m.id),
|
|
579
|
+
author: m.username || 'unknown',
|
|
580
|
+
basis: serverKnowsSelf ? 'self' : 'legacy-any-bot',
|
|
581
|
+
};
|
|
566
582
|
break;
|
|
567
583
|
}
|
|
568
584
|
}
|
|
@@ -571,7 +587,14 @@ export const performRun = ({
|
|
|
571
587
|
if (!replyText || replyText === 'NO_REPLY') {
|
|
572
588
|
log(`[${event.type}] no wrapper-post (${replyText === 'NO_REPLY' ? 'NO_REPLY' : 'empty output'})`);
|
|
573
589
|
} else if (agentPostedItself) {
|
|
574
|
-
|
|
590
|
+
// Name the message that caused the suppression. A silently dropped reply
|
|
591
|
+
// is invisible to everyone; #757 went unnoticed precisely because this
|
|
592
|
+
// line said only "posted via tool" with no way to tell whose post it saw.
|
|
593
|
+
log(
|
|
594
|
+
`[${event.type}] agent posted via tool this turn — not echoing CLI output `
|
|
595
|
+
+ `(avoids double-post; matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
596
|
+
+ `via ${suppressedBy.basis})`,
|
|
597
|
+
);
|
|
575
598
|
} else {
|
|
576
599
|
await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, {
|
|
577
600
|
content: replyText,
|
|
@@ -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
|
};
|