@phnx-labs/agents-cli 1.22.34 → 1.22.35

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.35
4
+
5
+ - **`agents share delete` (alias `agents unshare`) takes down a published page (RUSH-2428).** `agents share` publishes to a public URL with no way to take one down — the Cloudflare Worker already implements an authed `DELETE`, the CLI just never exposed it. The new command accepts a full share URL, `<user>/<slug>`, or a bare `<slug>` (resolved against your own namespace exactly as publish does), takes several targets at once, and by default also deletes the sibling `<slug>.png` OG cover (`--keep-cover` opts out) — without it, republishing over a slug replaced the page but left the old cover screenshot publicly readable, which is what made a real takedown slow. `{"ok":true}` from the Worker is not treated as proof: a follow-up check must resolve 404 before the command reports success, and it errors loudly (non-zero) instead of if it can't verify the object is actually gone. An already-missing target is an error by default; `--if-exists` treats it as a no-op success. Source: `apps/cli/src/lib/share/delete.ts`, `apps/cli/src/commands/share.ts`.
6
+
7
+ - **Codex `edit` runs can write under the repo's `.agents/` again.** Codex's `workspace-write` sandbox hardcodes any `.agents/` (and `.codex/`) directory as read-only, but agents-cli keeps every git worktree at `<repo>/.agents/worktrees/<slug>` — so a Codex session whose cwd was the repo root hit `EROFS: read-only file system` on any write into a worktree (a build's `dist/`, generated files) and then had to prompt for per-command approval to escalate. Every interactive, headless, and direct-launch Codex path now makes the run's `<repo-root>/.agents` writable: `agents run codex` (and the Windows shim delegate) add it to the `agents-edit` profile's `workspace_roots`, and the adopted POSIX `codex` shim resolves the repo's `.agents` from `$PWD` at run time (worktree-aware) and passes it via Codex's own `--add-dir`. (Routines use a separate overlay-HOME sandbox with their own `allow.dirs` and are unchanged.) Naming the `.agents` directory itself is the only override Codex honors — a nested sub-path makes bwrap refuse the mount. Only an existing `.agents` is added; out-of-workspace and `~/.config` writes stay gated, network unchanged. Source: `apps/cli/src/lib/codex-policy.ts`, `apps/cli/src/lib/project-key.ts`, `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/shims.ts`.
8
+
3
9
  ## 1.22.34
4
10
 
5
11
  - **A failed `agents teams add` no longer strands an `agents/<name>` branch that breaks every retry (RUSH-2356).** The worktree was created before the teammate record was persisted and nothing removed it when the add failed, so the next `teams add` with the same `--worktree` name died on `fatal: a branch named 'agents/<name>' already exists` — observed 2026-08-07, forcing three renames before a teammate could be created. Two guarantees now hold. Name uniqueness and the `--after` dependency graph are validated **before** `createWorktree` runs (`AgentManager.validateAddPreconditions`), so a duplicate name, unknown dependency, or cycle never creates a branch at all. A failure **after** the worktree exists — a missing harness CLI, a launch error, a cloud dispatch failure — removes the worktree and its branch before the command exits non-zero, and prints the exact `git worktree remove` / `git branch -D` pair if that teardown itself fails. Teardown is scoped twice over, because deleting a live teammate's worktree would destroy real work: only a worktree that same add created is a candidate, and only when no live teammate claims it (`AgentManager.isWorktreeClaimed`, a raw meta.json scan across every team, counting only non-terminal records). That second check matters because the add can fail *after* the record is durably saved — `spawn()` saves a staged teammate and only then runs the retention pass, which refreshes every sibling and can throw on a distributed one — so a teammate already recorded and waiting on an `--after` dependency keeps its worktree. If we can't prove a worktree is an orphan, it is left in place with the manual removal printed — the claim check **fails closed**, so an unreadable or half-written record answers "claimed" rather than "free": only a genuinely absent record (ENOENT) proves nothing claims the worktree, because the alternative error deletes a running teammate's uncommitted work. The pr-watch fixer path (`reactWithTeammate`) and a partially-failed `createWorktree` (branch ref created, checkout not) run the same guarded teardown — the fixer path matters because it stages its teammate with `--after` when it follows a source teammate, so it can reach the failure branch with a live, durably-recorded, merely-pending teammate already owning the worktree.
package/README.md CHANGED
@@ -1122,6 +1122,7 @@ agents share setup # once: provision bucket + W
1122
1122
  agents share plan.html --slug fleet --expire 30d # → https://<base>/fleet
1123
1123
  agents share plan.html --json # URL object for plan-render hooks
1124
1124
  agents share status # show the endpoint
1125
+ agents unshare fleet # take a published link (+ its OG cover) down
1125
1126
  ```
1126
1127
 
1127
1128
  `agents share` closes the loop: an agent makes work (a plan, a viz, a report),
@@ -1138,6 +1139,12 @@ publishes through it with a shared write token — `agents share join <baseUrl>`
1138
1139
  existing endpoint with no provisioning. `--expire 30d|12h|<date>` auto-expires a link.
1139
1140
  `--json` emits `{ url, coverUrl, expiresAt }` so plan-render automation can publish the
1140
1141
  rendered HTML and post the returned link without scraping terminal text.
1142
+
1143
+ `agents share delete <targets...>` (alias `agents unshare`) takes a page down — pass a
1144
+ full URL, `<user>/<slug>`, or a bare slug (resolved against your own namespace); several
1145
+ targets at once are fine. It also deletes the sibling `<slug>.png` OG cover by default
1146
+ (`--keep-cover` opts out) and verifies the page actually 404s before reporting success —
1147
+ the Worker's delete is idempotent, so `{"ok":true}` alone is never proof.
1141
1148
  See [docs/share.md](apps/cli/docs/share.md).
1142
1149
 
1143
1150
  ---
package/dist/bin/agents CHANGED
Binary file
@@ -1,7 +1,24 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type CloudflareRequester } from '../lib/share/provision.js';
3
3
  import { type PublishResult } from '../lib/share/publish.js';
4
+ import { deleteShare, type DeleteShareResult } from '../lib/share/delete.js';
4
5
  export declare function formatSharePublishResult(result: PublishResult, json?: boolean): string;
6
+ export declare function formatShareDeleteResult(result: DeleteShareResult, json?: boolean): string;
7
+ interface ShareDeleteCliOpts {
8
+ keepCover?: boolean;
9
+ ifExists?: boolean;
10
+ githubUser?: string;
11
+ json?: boolean;
12
+ }
13
+ /** Shared handler for `agents share delete <targets...>` and the top-level
14
+ * `agents unshare <targets...>` alias. Deletes each target independently and
15
+ * continues past a failed one (rm-style), reporting all results and exiting
16
+ * non-zero if any target failed to verify as gone.
17
+ *
18
+ * `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
19
+ * never exposed as a CLI flag, only used to inject a fake config/checker/deleter
20
+ * without touching the keychain or a live endpoint. */
21
+ export declare function runShareDelete(targets: string[], opts: ShareDeleteCliOpts, deleteFn?: typeof deleteShare): Promise<void>;
5
22
  export declare function registerShareCommands(program: Command): void;
6
23
  /** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
7
24
  * endpoint config + write token. Shared by `agents share setup` and the unified
@@ -22,3 +39,4 @@ export declare function runShareProvision(opts: {
22
39
  export declare function runShareJoin(baseUrl?: string, opts?: {
23
40
  token?: string;
24
41
  }): Promise<void>;
42
+ export {};
@@ -5,9 +5,11 @@ import chalk from 'chalk';
5
5
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
6
6
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, setWorkerSecret, } from '../lib/share/provision.js';
7
7
  import { publishFile } from '../lib/share/publish.js';
8
+ import { deleteShare } from '../lib/share/delete.js';
8
9
  import { renderWorkerScript } from '../lib/share/worker-template.js';
9
10
  import { analyticsEnabled } from '../lib/share/analytics.js';
10
11
  import { resolveGitHubUsername } from '../lib/git.js';
12
+ import { setHelpSections } from '../lib/help.js';
11
13
  export function formatSharePublishResult(result, json = false) {
12
14
  if (json)
13
15
  return JSON.stringify(result, null, 2);
@@ -18,6 +20,84 @@ export function formatSharePublishResult(result, json = false) {
18
20
  lines.push(chalk.dim(` expires ${new Date(result.expiresAt).toLocaleString()}`));
19
21
  return lines.join('\n');
20
22
  }
23
+ export function formatShareDeleteResult(result, json = false) {
24
+ if (json)
25
+ return JSON.stringify(result, null, 2);
26
+ if (result.skipped)
27
+ return chalk.dim(`skipped — ${result.url} was already gone`);
28
+ const lines = [chalk.green(`deleted ${result.url}`)];
29
+ if (result.cover) {
30
+ lines.push(result.cover.existedBefore
31
+ ? chalk.dim(` cover deleted ${result.cover.url}`)
32
+ : chalk.dim(` cover (none) ${result.cover.url}`));
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+ /** Shared handler for `agents share delete <targets...>` and the top-level
37
+ * `agents unshare <targets...>` alias. Deletes each target independently and
38
+ * continues past a failed one (rm-style), reporting all results and exiting
39
+ * non-zero if any target failed to verify as gone.
40
+ *
41
+ * `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
42
+ * never exposed as a CLI flag, only used to inject a fake config/checker/deleter
43
+ * without touching the keychain or a live endpoint. */
44
+ export async function runShareDelete(targets, opts, deleteFn = deleteShare) {
45
+ const results = [];
46
+ for (const target of targets) {
47
+ try {
48
+ const result = await deleteFn(target, {
49
+ keepCover: opts.keepCover === true,
50
+ ifExists: opts.ifExists === true,
51
+ githubUser: opts.githubUser,
52
+ });
53
+ results.push({ target, result });
54
+ if (!opts.json)
55
+ console.log(formatShareDeleteResult(result));
56
+ }
57
+ catch (e) {
58
+ results.push({ target, error: e.message });
59
+ if (!opts.json)
60
+ console.error(chalk.red(`${target}: ${e.message}`));
61
+ }
62
+ }
63
+ if (opts.json) {
64
+ console.log(JSON.stringify(results, null, 2));
65
+ }
66
+ if (results.some((r) => r.error)) {
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+ function registerShareDeleteOptions(cmd) {
71
+ return cmd
72
+ .option('--keep-cover', 'leave the sibling <slug>.png OG cover in place (default: delete it too)')
73
+ .option('--if-exists', 'treat an already-missing target as a no-op success instead of an error')
74
+ .option('--github-user <user>', 'GitHub username for resolving a bare-slug target (default: resolved from gh/git config)')
75
+ .option('--json', 'emit machine-readable results');
76
+ }
77
+ const SHARE_DELETE_EXAMPLES = `
78
+ # Delete by full URL — also takes down the sibling OG cover
79
+ agents share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
80
+
81
+ # Delete by <user>/<slug>, or a bare slug in your own namespace
82
+ agents share delete octocat/my-plan-a1b2
83
+ agents unshare my-plan-a1b2
84
+
85
+ # Several at once
86
+ agents unshare my-plan-a1b2 old-report-9f3c
87
+
88
+ # Keep the cover image up (rare — you usually want both gone)
89
+ agents unshare my-plan-a1b2 --keep-cover
90
+
91
+ # Don't error if it's already gone
92
+ agents unshare my-plan-a1b2 --if-exists
93
+ `;
94
+ const SHARE_DELETE_NOTES = `
95
+ A follow-up GET is required to resolve 404 before this reports success — the
96
+ Worker's DELETE is idempotent and returns {"ok":true} even for a key that was
97
+ never there, so that response alone is never proof of a takedown.
98
+
99
+ agents share delete === agents unshare (same command, different name).
100
+ `;
21
101
  export function registerShareCommands(program) {
22
102
  const shareCmd = program
23
103
  .command('share')
@@ -54,6 +134,34 @@ export function registerShareCommands(program) {
54
134
  process.exitCode = 1;
55
135
  }
56
136
  });
137
+ setHelpSections(shareCmd, {
138
+ examples: `
139
+ # Publish an HTML file — gets an auto OG cover + a shareable link
140
+ agents share ./out/plan.html
141
+
142
+ # Custom slug, expiring in 30 days
143
+ agents share ./out/report.html --slug q3-report --expire 30d
144
+ ${SHARE_DELETE_EXAMPLES}
145
+ # One-time setup (or join an existing endpoint)
146
+ agents share setup
147
+ agents share join https://share.agents-cli.sh
148
+ `,
149
+ notes: SHARE_DELETE_NOTES,
150
+ });
151
+ const shareDeleteCmd = registerShareDeleteOptions(shareCmd
152
+ .command('delete <targets...>')
153
+ .description('Take down a published page (and by default its OG cover). Verifies the page 404s before reporting success. Top-level alias: agents unshare.'));
154
+ setHelpSections(shareDeleteCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
155
+ shareDeleteCmd.action(async (targets, opts) => {
156
+ await runShareDelete(targets, opts);
157
+ });
158
+ const unshareCmd = registerShareDeleteOptions(program
159
+ .command('unshare <targets...>')
160
+ .description('Alias of `agents share delete` — take down a published page (and by default its OG cover).'));
161
+ setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
162
+ unshareCmd.action(async (targets, opts) => {
163
+ await runShareDelete(targets, opts);
164
+ });
57
165
  shareCmd
58
166
  .command('setup')
59
167
  .description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
@@ -1,7 +1,15 @@
1
1
  export type CodexPolicyMode = 'plan' | 'edit' | 'skip';
2
2
  export declare const CODEX_PLAN_PROFILE = "agents-plan";
3
3
  export declare const CODEX_EDIT_PROFILE = "agents-edit";
4
- export declare function codexEditWritableRoots(): string[];
4
+ /**
5
+ * Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
6
+ * baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
7
+ * `.agents` directory. The last entry is what lets an in-repo build write under
8
+ * `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
9
+ * read-only, and naming the directory as an explicit writable root is the only
10
+ * thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
11
+ */
12
+ export declare function codexEditWritableRoots(cwd?: string): string[];
5
13
  export declare function codexPermissionProfileConfig(mode: Exclude<CodexPolicyMode, 'skip'>, writableRoots?: string[]): string;
6
14
  /**
7
15
  * Canonical Codex safety policy used by every native launch path.
@@ -1,12 +1,27 @@
1
+ import * as fs from 'fs';
1
2
  import { getUserAgentsDir } from './state.js';
2
3
  import { codexDefaultWritableRoots } from './permissions.js';
4
+ import { repoAgentsDirForCwd } from './project-key.js';
3
5
  export const CODEX_PLAN_PROFILE = 'agents-plan';
4
6
  export const CODEX_EDIT_PROFILE = 'agents-edit';
5
7
  function unique(values) {
6
8
  return [...new Set(values)];
7
9
  }
8
- export function codexEditWritableRoots() {
9
- return unique([getUserAgentsDir(), ...codexDefaultWritableRoots()]);
10
+ /**
11
+ * Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
12
+ * baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
13
+ * `.agents` directory. The last entry is what lets an in-repo build write under
14
+ * `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
15
+ * read-only, and naming the directory as an explicit writable root is the only
16
+ * thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
17
+ */
18
+ export function codexEditWritableRoots(cwd) {
19
+ const repoAgents = repoAgentsDirForCwd(cwd);
20
+ // Only widen the sandbox for a `.agents` that actually exists — most repos
21
+ // have none, and there is no point naming a directory that isn't there. (Codex
22
+ // tolerates a missing writable root, so this is tidiness, not a hard guard.)
23
+ const repoRoots = repoAgents && fs.existsSync(repoAgents) ? [repoAgents] : [];
24
+ return unique([getUserAgentsDir(), ...codexDefaultWritableRoots(), ...repoRoots]);
10
25
  }
11
26
  function inlineWorkspaceRoots(roots) {
12
27
  return roots.map((root) => `${JSON.stringify(root)} = true`).join(', ');
package/dist/lib/exec.js CHANGED
@@ -939,7 +939,7 @@ export function buildExecCommand(options) {
939
939
  }
940
940
  if (options.agent === 'codex') {
941
941
  const policyMode = resolvedMode === 'plan' || resolvedMode === 'skip' ? resolvedMode : 'edit';
942
- const writableRoots = [...codexEditWritableRoots(), ...(options.addDirs ?? [])];
942
+ const writableRoots = [...codexEditWritableRoots(options.cwd ?? process.cwd()), ...(options.addDirs ?? [])];
943
943
  cmd.push(...codexPolicyArgs(policyMode, writableRoots));
944
944
  }
945
945
  else if (resumeSpec && 'subcommand' in resumeSpec) {
@@ -1181,7 +1181,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1181
1181
  // Match the POSIX shim: direct Codex launches default to the safe writable
1182
1182
  // profile, while later user arguments can still override native settings.
1183
1183
  const launchArgs = agent === 'codex'
1184
- ? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit')]
1184
+ ? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit', codexEditWritableRoots(cwd))]
1185
1185
  : [];
1186
1186
  // Mint a launch id and export it as AGENT_LAUNCH_ID so the agent's SessionStart
1187
1187
  // hook records the same id — the join key that maps this launch to its exact
@@ -31,6 +31,23 @@ export declare function projectKeyFromCwd(cwd?: string | null): string | undefin
31
31
  * non-project directory under it into one bogus "project".
32
32
  */
33
33
  export declare function repoRootForCwd(dir: string, home?: string): string | undefined;
34
+ /**
35
+ * The main-repo `.agents` directory for a cwd, or `undefined` when the cwd is
36
+ * not inside a repo.
37
+ *
38
+ * For a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) this resolves
39
+ * to the PRIMARY repo's `.agents` — the directory that actually holds the
40
+ * worktrees — so a run started anywhere in the repo can write into any worktree.
41
+ * For any other in-repo cwd it is `<repo-root>/.agents`.
42
+ *
43
+ * The single caller is Codex's `edit`-mode writable-root list: Codex's
44
+ * `workspace-write` sandbox hardcodes `.agents/` (and `.codex/`) as read-only,
45
+ * but naming the `.agents` directory itself as an explicit writable root
46
+ * overrides that — so an in-repo build/test never hits EROFS on a path under
47
+ * `.agents/worktrees/`. Filesystem-only (no `git` process): reuses the same
48
+ * worktree-segment fold as {@link projectKeyFromCwd}.
49
+ */
50
+ export declare function repoAgentsDirForCwd(cwd?: string | null, home?: string): string | undefined;
34
51
  /**
35
52
  * Resolve the project key for a cwd **on this machine**: the repository it
36
53
  * belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
@@ -61,6 +61,32 @@ export function repoRootForCwd(dir, home = os.homedir()) {
61
61
  current = parent;
62
62
  }
63
63
  }
64
+ /**
65
+ * The main-repo `.agents` directory for a cwd, or `undefined` when the cwd is
66
+ * not inside a repo.
67
+ *
68
+ * For a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) this resolves
69
+ * to the PRIMARY repo's `.agents` — the directory that actually holds the
70
+ * worktrees — so a run started anywhere in the repo can write into any worktree.
71
+ * For any other in-repo cwd it is `<repo-root>/.agents`.
72
+ *
73
+ * The single caller is Codex's `edit`-mode writable-root list: Codex's
74
+ * `workspace-write` sandbox hardcodes `.agents/` (and `.codex/`) as read-only,
75
+ * but naming the `.agents` directory itself as an explicit writable root
76
+ * overrides that — so an in-repo build/test never hits EROFS on a path under
77
+ * `.agents/worktrees/`. Filesystem-only (no `git` process): reuses the same
78
+ * worktree-segment fold as {@link projectKeyFromCwd}.
79
+ */
80
+ export function repoAgentsDirForCwd(cwd, home) {
81
+ if (!cwd)
82
+ return undefined;
83
+ const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '').trim();
84
+ if (!norm)
85
+ return undefined;
86
+ const wtIdx = norm.indexOf(WORKTREE_SEGMENT);
87
+ const repoRoot = wtIdx > 0 ? norm.slice(0, wtIdx) : repoRootForCwd(norm, home);
88
+ return repoRoot ? path.join(repoRoot, '.agents') : undefined;
89
+ }
64
90
  /**
65
91
  * Resolve the project key for a cwd **on this machine**: the repository it
66
92
  * belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
@@ -0,0 +1,93 @@
1
+ import { type ShareConfig } from './config.js';
2
+ /** DI seam for tests — override the real HTTP DELETE. */
3
+ export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
4
+ ok: boolean;
5
+ status: number;
6
+ }>;
7
+ /** DI seam for tests — override the real HTTP existence check (HEAD). */
8
+ export type CheckFn = (url: string) => Promise<{
9
+ status: number;
10
+ }>;
11
+ export interface DeleteEndpoint {
12
+ baseUrl: string;
13
+ token: string;
14
+ }
15
+ export interface ResolvedShareTarget {
16
+ /** R2 object key for the page, `<user>/<slug>`. */
17
+ key: string;
18
+ /** R2 object key for the sibling OG cover, `<user>/<slug>.png`. */
19
+ coverKey: string;
20
+ }
21
+ /**
22
+ * Normalize any of the three accepted target forms to the R2 key that publish
23
+ * would have written:
24
+ * - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
25
+ * - `<user>/<slug>`
26
+ * - a bare `<slug>` — resolved against the caller's own namespace exactly as
27
+ * `publishToEndpoint` resolves it at publish time (resolveShareUsername +
28
+ * buildShareKey), so a bare slug always targets *your* published page.
29
+ *
30
+ * The URL and `<user>/<slug>` forms are taken as already the exact key a prior
31
+ * publish produced (no re-sanitizing) — only the bare-slug form runs through
32
+ * `buildShareKey`, because that's the one case where the slug hasn't already
33
+ * been normalized by a publish.
34
+ */
35
+ export declare function resolveDeleteTarget(target: string, opts?: {
36
+ githubUser?: string;
37
+ }): Promise<ResolvedShareTarget>;
38
+ export interface DeleteObjectResult {
39
+ key: string;
40
+ url: string;
41
+ /** Whether the object resolved (non-404) before the DELETE was issued. */
42
+ existedBefore: boolean;
43
+ /** Whether the Worker's DELETE call itself reported ok. */
44
+ deleted: boolean;
45
+ /** The postcondition: a follow-up check resolved 404 after the DELETE. */
46
+ verified404: boolean;
47
+ }
48
+ /**
49
+ * Delete one R2 object behind the share Worker and assert the postcondition.
50
+ * `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
51
+ * idempotent, so a DELETE on a key that was never there also returns `ok:true`.
52
+ * This checks existence before (so callers can tell "deleted" from "was never
53
+ * there") and re-checks after (so callers can tell "deleted" from "still public").
54
+ */
55
+ export declare function deleteObject(endpoint: DeleteEndpoint, key: string, opts?: {
56
+ deleter?: DeleteFn;
57
+ checker?: CheckFn;
58
+ }): Promise<DeleteObjectResult>;
59
+ export interface DeleteShareOptions {
60
+ /** Skip deleting the sibling `<slug>.png` OG cover (default: delete it too). */
61
+ keepCover?: boolean;
62
+ /** Treat an already-missing target as a no-op success instead of an error
63
+ * (mirrors SQL's `DROP ... IF EXISTS`). Default: missing target is an error. */
64
+ ifExists?: boolean;
65
+ /** Override the GitHub username used to resolve a bare-slug target. */
66
+ githubUser?: string;
67
+ /** DI seam for tests — override the persisted share endpoint config. */
68
+ config?: ShareConfig;
69
+ /** DI seam for tests — override the keychain-backed write token. */
70
+ writeToken?: string;
71
+ /** DI seam for tests — override the real HTTP DELETE. */
72
+ deleter?: DeleteFn;
73
+ /** DI seam for tests — override the real HTTP existence check. */
74
+ checker?: CheckFn;
75
+ }
76
+ export interface DeleteShareResult {
77
+ key: string;
78
+ url: string;
79
+ existedBefore: boolean;
80
+ verified404: boolean;
81
+ /** True when `ifExists` was set and the target was already gone — nothing ran. */
82
+ skipped?: boolean;
83
+ cover?: {
84
+ key: string;
85
+ url: string;
86
+ existedBefore: boolean;
87
+ verified404: boolean;
88
+ };
89
+ }
90
+ /** Delete one share target (page + by default its OG cover) and verify both
91
+ * are gone. Throws on an unverified takedown — never reports success for an
92
+ * object that still resolves. */
93
+ export declare function deleteShare(target: string, opts?: DeleteShareOptions): Promise<DeleteShareResult>;
@@ -0,0 +1,127 @@
1
+ // The delete path for `agents share delete` / `agents unshare` — an authed DELETE
2
+ // to the Worker, which already implements it (worker-template.ts). Mirrors
3
+ // publish.ts: pure target-resolution logic is exported for tests, the network
4
+ // calls (a status check + a delete) sit behind an injectable DI seam.
5
+ //
6
+ // The Worker's R2 delete is idempotent — DELETE on a key that never existed still
7
+ // returns `{"ok":true}` — so `{"ok":true}` alone is never proof of a takedown.
8
+ // Every delete here is followed by a status check that must observe 404 before
9
+ // the operation is reported as successful.
10
+ import { readShareConfig, readWriteToken } from './config.js';
11
+ import { buildShareKey, resolveShareUsername } from './publish.js';
12
+ /**
13
+ * Normalize any of the three accepted target forms to the R2 key that publish
14
+ * would have written:
15
+ * - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
16
+ * - `<user>/<slug>`
17
+ * - a bare `<slug>` — resolved against the caller's own namespace exactly as
18
+ * `publishToEndpoint` resolves it at publish time (resolveShareUsername +
19
+ * buildShareKey), so a bare slug always targets *your* published page.
20
+ *
21
+ * The URL and `<user>/<slug>` forms are taken as already the exact key a prior
22
+ * publish produced (no re-sanitizing) — only the bare-slug form runs through
23
+ * `buildShareKey`, because that's the one case where the slug hasn't already
24
+ * been normalized by a publish.
25
+ */
26
+ export async function resolveDeleteTarget(target, opts = {}) {
27
+ const trimmed = target.trim();
28
+ if (!trimmed)
29
+ throw new Error('Share target is empty.');
30
+ let key;
31
+ if (/^https?:\/\//i.test(trimmed)) {
32
+ const url = new URL(trimmed);
33
+ const segments = url.pathname
34
+ .replace(/^\/+|\/+$/g, '')
35
+ .split('/')
36
+ .filter(Boolean)
37
+ .map(decodeURIComponent);
38
+ if (segments.length < 2) {
39
+ throw new Error(`Not a share page URL (expected .../<user>/<slug>): ${trimmed}`);
40
+ }
41
+ key = segments.slice(0, 2).join('/');
42
+ }
43
+ else if (trimmed.includes('/')) {
44
+ const segments = trimmed.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
45
+ if (segments.length !== 2) {
46
+ throw new Error(`Expected <user>/<slug>, got: ${trimmed}`);
47
+ }
48
+ key = segments.join('/');
49
+ }
50
+ else {
51
+ const username = await resolveShareUsername(opts);
52
+ key = buildShareKey(username, trimmed);
53
+ }
54
+ return { key, coverKey: `${key}.png` };
55
+ }
56
+ async function defaultCheck(url) {
57
+ const res = await fetch(url, { method: 'HEAD' });
58
+ return { status: res.status };
59
+ }
60
+ async function defaultDelete(url, headers) {
61
+ const res = await fetch(url, { method: 'DELETE', headers });
62
+ return { ok: res.ok, status: res.status };
63
+ }
64
+ /**
65
+ * Delete one R2 object behind the share Worker and assert the postcondition.
66
+ * `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
67
+ * idempotent, so a DELETE on a key that was never there also returns `ok:true`.
68
+ * This checks existence before (so callers can tell "deleted" from "was never
69
+ * there") and re-checks after (so callers can tell "deleted" from "still public").
70
+ */
71
+ export async function deleteObject(endpoint, key, opts = {}) {
72
+ const url = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
73
+ const check = opts.checker ?? defaultCheck;
74
+ const del = opts.deleter ?? defaultDelete;
75
+ const before = await check(url);
76
+ const existedBefore = before.status !== 404;
77
+ const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
78
+ if (!r.ok) {
79
+ throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents share setup' completed.`);
80
+ }
81
+ const after = await check(url);
82
+ const verified404 = after.status === 404;
83
+ return { key, url, existedBefore, deleted: r.ok, verified404 };
84
+ }
85
+ /** Delete one share target (page + by default its OG cover) and verify both
86
+ * are gone. Throws on an unverified takedown — never reports success for an
87
+ * object that still resolves. */
88
+ export async function deleteShare(target, opts = {}) {
89
+ const cfg = opts.config ?? readShareConfig();
90
+ if (!cfg) {
91
+ throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
92
+ }
93
+ const token = opts.writeToken ?? readWriteToken();
94
+ const resolved = await resolveDeleteTarget(target, { githubUser: opts.githubUser });
95
+ const endpoint = { baseUrl: cfg.baseUrl, token };
96
+ const page = await deleteObject(endpoint, resolved.key, { deleter: opts.deleter, checker: opts.checker });
97
+ if (!page.existedBefore) {
98
+ if (opts.ifExists) {
99
+ return { key: page.key, url: page.url, existedBefore: false, verified404: page.verified404, skipped: true };
100
+ }
101
+ throw new Error(`Nothing to delete — ${page.url} was already not found. Pass --if-exists to treat this as a no-op instead of an error.`);
102
+ }
103
+ if (!page.verified404) {
104
+ throw new Error(`Delete reported success but ${page.url} still resolves — takedown NOT verified. Retry, or investigate the Worker/R2 directly.`);
105
+ }
106
+ const result = {
107
+ key: page.key,
108
+ url: page.url,
109
+ existedBefore: page.existedBefore,
110
+ verified404: page.verified404,
111
+ };
112
+ if (!opts.keepCover) {
113
+ const cover = await deleteObject(endpoint, resolved.coverKey, { deleter: opts.deleter, checker: opts.checker });
114
+ result.cover = {
115
+ key: cover.key,
116
+ url: cover.url,
117
+ existedBefore: cover.existedBefore,
118
+ verified404: cover.verified404,
119
+ };
120
+ // A missing cover is normal (non-HTML publishes, or --no-cover at publish
121
+ // time never made one) — only a cover that existed and is still up is a bug.
122
+ if (cover.existedBefore && !cover.verified404) {
123
+ throw new Error(`Cover delete reported success but ${cover.url} still resolves — takedown NOT verified. Retry, or pass --keep-cover and delete it manually.`);
124
+ }
125
+ }
126
+ return result;
127
+ }
package/dist/lib/shims.js CHANGED
@@ -236,6 +236,43 @@ function codexShimLaunchArgs() {
236
236
  ...codexPolicyArgs('edit'),
237
237
  ].map(shellQuote).join(' ');
238
238
  }
239
+ /**
240
+ * The `exec` tail for a generated shim. For most agents this is a plain
241
+ * `exec "$BINARY"<launchArgs> "$@"`.
242
+ *
243
+ * Codex is special: its `workspace-write` sandbox hardcodes any `.agents/` (and
244
+ * `.codex/`) directory read-only, but agents-cli keeps every worktree at
245
+ * `<repo>/.agents/worktrees/<slug>`, so an in-repo build under a static
246
+ * shim would hit `EROFS`. The `agents run codex` path fixes this by adding
247
+ * `<repo>/.agents` to the profile's `workspace_roots` (see codexEditWritableRoots
248
+ * / repoAgentsDirForCwd), but a static shim has no cwd at generation time. So the
249
+ * shim resolves the repo's `.agents` from `$PWD` at RUN time — worktree-aware,
250
+ * mirroring repoAgentsDirForCwd — and passes it via Codex's own `--add-dir`
251
+ * (verified to compose with the `agents-edit` profile). The resolution is inline
252
+ * bash because the shim is the launch hot path and must not spawn Node to compute
253
+ * one directory. `--add-dir` is added only when the resolved `.agents` exists.
254
+ */
255
+ function shimExecTail(agent, launchArgs) {
256
+ if (agent !== 'codex')
257
+ return `exec "$BINARY"${launchArgs} "$@"`;
258
+ return `_repo_agents=""
259
+ case "$PWD" in
260
+ */.agents/worktrees/*) _repo_agents="\${PWD%%/.agents/worktrees/*}/.agents" ;;
261
+ *)
262
+ _d="$PWD"
263
+ # Stop before $HOME so a dotfiles repo at $HOME is not treated as the project
264
+ # root (mirrors repoRootForCwd's home exclusion in project-key.ts).
265
+ while [ -n "$_d" ] && [ "$_d" != "/" ] && [ "$_d" != "$HOME" ]; do
266
+ if [ -e "$_d/.git" ]; then _repo_agents="$_d/.agents"; break; fi
267
+ _d=$(dirname "$_d")
268
+ done
269
+ ;;
270
+ esac
271
+ if [ -n "$_repo_agents" ] && [ -d "$_repo_agents" ]; then
272
+ exec "$BINARY"${launchArgs} --add-dir "$_repo_agents" "$@"
273
+ fi
274
+ exec "$BINARY"${launchArgs} "$@"`;
275
+ }
239
276
  function getAgentsBinForGeneratedShim() {
240
277
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
241
278
  }
@@ -707,7 +744,7 @@ if [ "\$LAUNCH_SKIP" = "0" ]; then
707
744
  "\$AGENTS_BIN" sync --agent "\$AGENT" --agent-version "\$VERSION" --launch --cwd "\$PWD" --quiet 2>/dev/null || true
708
745
  fi
709
746
 
710
- exec "$BINARY"${launchArgs} "$@"
747
+ ${shimExecTail(agent, launchArgs)}
711
748
  `;
712
749
  }
713
750
  /**
@@ -1094,7 +1131,7 @@ if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
1094
1131
  fi
1095
1132
  ${managedEnv}
1096
1133
 
1097
- exec "$BINARY"${launchArgs} "$@"
1134
+ ${shimExecTail(agent, launchArgs)}
1098
1135
  `;
1099
1136
  }
1100
1137
  /**
@@ -268,6 +268,9 @@ export const COMMAND_LOADERS = {
268
268
  mailbox: [loadMailboxes],
269
269
  serve: [loadServe],
270
270
  share: [loadShare],
271
+ // `unshare` is a top-level convenience alias of `share delete` (see
272
+ // commands/share.ts) — same module, registered as its own program.command().
273
+ unshare: [loadShare],
271
274
  audit: [loadAudit],
272
275
  webhook: [loadWebhook],
273
276
  funnel: [loadFunnel],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.34",
3
+ "version": "1.22.35",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",