argocd-axi 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -48,8 +48,8 @@ argocd-axi turns that into small, definitive answers:
48
48
  the raw text is one `--full` away.
49
49
  - **Opt-in writes.** `apps sync <app>` previews the per-resource diff and
50
50
  makes no change unless `--confirm` is passed. It never prunes unless
51
- `--prune` is explicit, and execution is limited to configured writable
52
- contexts. Other writes remain refused.
51
+ `--prune` is explicit. Resource actions and automated-sync changes also
52
+ preview by default and execute only on configured writable contexts.
53
53
  - **Safe output.** Secret data, passwords, tokens and sensitive-looking Helm
54
54
  parameters are redacted and counted. No flag reveals them.
55
55
  - **Never hangs.** Nothing streams or follows; waits are bounded; no prompt can
@@ -121,14 +121,20 @@ and every other command that prints or changes a credential.
121
121
 
122
122
  Pick one; you do not need both.
123
123
 
124
- - **Session hook (recommended).** `argocd-axi setup hooks` installs a
125
- SessionStart hook for Claude Code, Codex and OpenCode. The hook runs
126
- `argocd-axi-ctx`, which is local only: current context, server, CLI version
127
- and the cached server version. It makes no network call, triggers no SSO
128
- refresh and reads no credential, so a dead context can never hang a session
129
- start. It needs a persistent install (`npm install -g argocd-axi`); run from
130
- an npx cache, it answers `REFUSED` because the hook would point at a
131
- temporary path.
124
+ - **Session hook (recommended).** `argocd-axi setup hooks` installs
125
+ SessionStart hooks for Claude Code, Codex and OpenCode, plus a Claude Code
126
+ SessionEnd hook. The start hook runs `argocd-axi-ctx`, which is local only:
127
+ current context, server, CLI version and the cached server version. It makes
128
+ no network call, triggers no SSO refresh and reads no credential, so a dead
129
+ context can never hang a session start. The end hook records only argocd-axi
130
+ command names, their counts and the most-used `--context` value for that
131
+ working directory; it does not save arguments or other values. The session
132
+ log is limited to 100 directories and stored with `0600` permissions. Run
133
+ `argocd-axi context` to view the current directory's last session, or
134
+ `argocd-axi setup status` / `argocd-axi setup uninstall` to inspect or remove
135
+ the Claude Code SessionEnd hook. Hooks need a persistent install
136
+ (`npm install -g argocd-axi`); run from an npx cache, installation is refused
137
+ because hooks would point at a temporary path.
132
138
  - **Skill.** `npx skills add Dematom1/argocd-axi --skill argocd-axi`
133
139
  installs [`skills/argocd-axi/SKILL.md`](skills/argocd-axi/SKILL.md), which
134
140
  is generated from the CLI's own help and loads when a task is about Argo CD.
@@ -146,15 +152,18 @@ argocd-axi apps view <app> [--full]
146
152
  argocd-axi apps resources <app> [--problems | --orphaned]
147
153
  argocd-axi apps diff <app> [--revision <rev>] [--full]
148
154
  argocd-axi apps sync <app> [--confirm] [--prune] [--timeout <s>] --context <c>
155
+ argocd-axi apps auto-sync <app> <on|off> [--confirm] --context <c>
149
156
  argocd-axi apps history <app> [--limit <n>]
150
157
  argocd-axi apps manifests <app> [--source git|live] [--revision <rev>] [--kind <k>] [--full]
151
158
  argocd-axi apps logs <app> [--kind <k> --name <n>] [--tail 100] [--since 15m] [--filter <s>] [--full] [-p]
152
159
  argocd-axi apps wait <app> [--sync --health --operation] [--timeout 120]
153
160
  argocd-axi apps actions <app> [--kind <k>]
161
+ argocd-axi apps actions run <app> <action> --kind <k> --name <resource> [--confirm] --context <c>
154
162
  argocd-axi projects | projects view <p> | projects windows <p>
155
163
  argocd-axi appsets | appsets view <name> | appsets generate <file>
156
164
  argocd-axi repos | clusters
157
- argocd-axi setup hooks
165
+ argocd-axi context [end]
166
+ argocd-axi setup hooks | status | uninstall
158
167
  argocd-axi update [--check]
159
168
  ```
160
169
 
@@ -219,7 +228,7 @@ specific app.
219
228
  local-file and delete flags whatever a command passes, gives argocd no
220
229
  stdin, and enforces a timeout and an output cap. Tests fail if a new
221
230
  operation becomes reachable without updating the registry.
222
- - **Opt-in Application writes.** Sync, rollback, refresh and terminate-op require an explicit context, preview and `--confirm`; execution is limited by `writable-contexts` and Argo CD RBAC. Other writes and credential commands answer `REFUSED`.
231
+ - **Opt-in Application writes.** Sync, rollback, refresh, terminate-op, resource actions and automated-sync changes require an explicit context, preview and `--confirm`; execution is limited by `writable-contexts` and Argo CD RBAC. Other writes and credential commands answer `REFUSED`.
223
232
  - **Redaction.** Kubernetes Secret `data`, `stringData` and their
224
233
  last-applied annotation, values under sensitive-looking keys, and env-var or
225
234
  Helm-parameter pairs with sensitive-looking names are replaced with
@@ -231,9 +240,11 @@ specific app.
231
240
  - **Writable contexts.** argocd-axi's own config file,
232
241
  `~/.config/argocd-axi/config` (or `$XDG_CONFIG_HOME/argocd-axi/config`),
233
242
  holds the allowlist of contexts where Application writes may execute.
234
- It defaults to none. To allow writes on a context, a human runs
235
- `argocd-axi ctx writable add <context>` in a terminal and confirms the exact
236
- context name. Use `argocd-axi ctx writable remove <context>` to revoke access.
243
+ It defaults to none. To allow writes, a human runs
244
+ `argocd-axi ctx writable add` in a terminal to select a configured context,
245
+ or passes `<context>` directly; either path requires an explicit y/yes
246
+ confirmation. Non-interactive runs are refused. Use
247
+ `argocd-axi ctx writable remove <context>` to revoke access.
237
248
 
238
249
  - **Version cache.** The server version is checked at most once every 12
239
250
  hours per context and server, and cached in `~/.cache/argocd-axi/`
package/dist/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { homeCommand } from "./commands/home.js";
10
10
  import { clustersCommand, reposCommand } from "./commands/inventory.js";
11
11
  import { projectsCommand } from "./commands/projects.js";
12
12
  import { setupCommand } from "./commands/setup.js";
13
+ import { contextCommand } from "./commands/context.js";
13
14
  import { DESCRIPTION, TOP_HELP } from "./help.js";
14
15
  import { NATIVE_NOUNS, REFUSALS } from "./registry.js";
15
16
  import { renderBlocks, renderHelp } from "./toon.js";
@@ -55,6 +56,7 @@ export const COMMANDS = Object.assign(Object.create(null), {
55
56
  repos: reposCommand,
56
57
  clusters: clustersCommand,
57
58
  setup: setupCommand,
59
+ context: contextCommand,
58
60
  ...Object.fromEntries(TOP_LEVEL_REFUSALS.map((key) => [key, refusalCommand(key)])),
59
61
  });
60
62
  export async function main(argv = process.argv.slice(2)) {
@@ -1,8 +1,9 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { parseAllDocuments, stringify } from "yaml";
3
4
  import { argocdJson, runArgocd } from "../argocd.js";
4
5
  import { loadConfig } from "../config.js";
5
- import { evaluateWriteGate } from "../gate.js";
6
+ import { attributionArgs, evaluateWriteGate } from "../gate.js";
6
7
  import { CONTEXT_FLAG, getBool, getInt, getList, getString, parseArgs, parseDurationSeconds, validateName, } from "../args.js";
7
8
  import { CODES, axiError, mapArgocdFailure, validationError, } from "../errors.js";
8
9
  import { appName, appSources, attentionReasonPreview, attentionReasons, autoSyncSummary, destinationSummary, displayNames, formatAge, healthStatus, isOperationRunning, isResourceProblem, isUnhealthy, needsAttention, shortRevision, sortApps, syncRevision, syncStatus, plural, truncate, } from "../model.js";
@@ -1149,9 +1150,7 @@ export const APPS_ACTIONS_SPEC = {
1149
1150
  "argocd-axi apps actions guestbook",
1150
1151
  "argocd-axi apps actions guestbook --kind Rollout",
1151
1152
  ],
1152
- notes: [
1153
- "Running an action is planned for a later write tier; this command only lists them",
1154
- ],
1153
+ notes: ["List actions before running one with `apps actions run`"],
1155
1154
  };
1156
1155
  function pick(record, key) {
1157
1156
  const found = Object.keys(record).find((k) => k.toLowerCase() === key);
@@ -1159,7 +1158,7 @@ function pick(record, key) {
1159
1158
  }
1160
1159
  export async function appsActions(args) {
1161
1160
  if (args[0] === "run")
1162
- refuseActionRun();
1161
+ return appsActionRun(args.slice(1));
1163
1162
  const parsed = parseArgs(APPS_ACTIONS_SPEC, args);
1164
1163
  const name = validateName("app", parsed.positionals[0]);
1165
1164
  const argv = [name, "-o", "json"];
@@ -1185,10 +1184,126 @@ export async function appsActions(args) {
1185
1184
  }));
1186
1185
  return finish([encodeObject({ count: plural(rows.length, "action"), actions: rows })], undefined);
1187
1186
  }
1188
- function refuseActionRun() {
1189
- throw axiError("running resource actions is planned for the write tier, behind preview-by-default and the writable-contexts allowlist; not in v1 (see ROADMAP.md)", CODES.REFUSED, [
1190
- "A human can run one with the raw CLI: `argocd app actions run <app> <action> --kind <kind>`",
1191
- ]);
1187
+ const APPS_ACTION_RUN_SPEC = {
1188
+ path: "apps actions run",
1189
+ summary: "Preview and optionally run one action on one resource",
1190
+ positionals: [
1191
+ APP_ARG,
1192
+ { name: "action", required: true, description: "Listed resource action" },
1193
+ ],
1194
+ flags: [
1195
+ {
1196
+ name: "--kind",
1197
+ kind: "value",
1198
+ valueName: "kind",
1199
+ description: "Resource kind",
1200
+ },
1201
+ {
1202
+ name: "--name",
1203
+ kind: "value",
1204
+ valueName: "resource",
1205
+ description: "Resource name",
1206
+ },
1207
+ {
1208
+ name: "--group",
1209
+ kind: "value",
1210
+ valueName: "group",
1211
+ description: "Resource API group when needed to disambiguate",
1212
+ },
1213
+ {
1214
+ name: "--namespace",
1215
+ kind: "value",
1216
+ valueName: "namespace",
1217
+ description: "Resource namespace when needed to disambiguate",
1218
+ },
1219
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1220
+ {
1221
+ name: "--timeout",
1222
+ kind: "value",
1223
+ valueName: "s",
1224
+ description: "Maximum wait in seconds (default 120)",
1225
+ },
1226
+ CONTEXT_FLAG,
1227
+ ],
1228
+ examples: [
1229
+ "argocd-axi apps actions run guestbook restart --kind Deployment --name guestbook --context staging",
1230
+ ],
1231
+ };
1232
+ async function appsActionRun(args) {
1233
+ const parsed = parseArgs(APPS_ACTION_RUN_SPEC, args);
1234
+ const appNameArg = validateName("app", parsed.positionals[0]);
1235
+ const action = validateName("action", parsed.positionals[1]);
1236
+ const kind = getString(parsed, "--kind");
1237
+ const resourceName = getString(parsed, "--name");
1238
+ const group = getString(parsed, "--group");
1239
+ const namespace = getString(parsed, "--namespace");
1240
+ const timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1241
+ if (!kind || !resourceName)
1242
+ throw validationError("apps actions run needs --kind and --name");
1243
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm");
1244
+ const gate = evaluateWriteGate({
1245
+ command: "apps actions run",
1246
+ context,
1247
+ execute: confirm,
1248
+ executeFlag: "--confirm",
1249
+ }, loadConfig().config);
1250
+ const options = { context: gate.context };
1251
+ const app = await getApp(appNameArg, options);
1252
+ const resources = (app.status?.resources ?? []).filter((r) => r.kind === kind &&
1253
+ r.name === resourceName &&
1254
+ (group === undefined || r.group === group) &&
1255
+ (namespace === undefined || r.namespace === namespace));
1256
+ const resource = resources[0];
1257
+ if (!resource)
1258
+ throw axiError(`REFUSED: ${kind}/${resourceName} is not managed by ${appNameArg}`, CODES.REFUSED);
1259
+ if (resources.length > 1)
1260
+ throw axiError(`REFUSED: ${kind}/${resourceName} is ambiguous; candidates: ${resources
1261
+ .map((r) => `${r.group ?? "core"}/${r.kind} ${r.namespace ?? "_cluster"}/${r.name}`)
1262
+ .join(", ")}. Specify --group and/or --namespace`, CODES.REFUSED);
1263
+ const actions = await argocdJson("app actions list", [appNameArg, "--kind", kind, "-o", "json"], options);
1264
+ const matchingActions = actions.filter((entry) => String(pick(entry, "name") ?? "") === resourceName &&
1265
+ String(pick(entry, "group") ?? "") === (resource.group ?? "") &&
1266
+ String(pick(entry, "kind") ?? "") === kind &&
1267
+ String(pick(entry, "action") ?? "") === action);
1268
+ const namespacedActions = matchingActions.filter((entry) => pick(entry, "namespace") !== undefined);
1269
+ const unnamespacedAction = matchingActions.length === 1 ? matchingActions[0] : undefined;
1270
+ const actionAvailable = namespacedActions.length
1271
+ ? namespacedActions.some((entry) => pick(entry, "namespace") === resource.namespace &&
1272
+ pick(entry, "disabled") !== true)
1273
+ : unnamespacedAction !== undefined &&
1274
+ pick(unnamespacedAction, "disabled") !== true;
1275
+ if (!actionAvailable)
1276
+ throw axiError(`REFUSED: action ${action} is unavailable for ${kind}/${resourceName}`, CODES.REFUSED);
1277
+ const blocks = [
1278
+ encodeObject({ action, app: appNameArg, kind, resource: resourceName }),
1279
+ ];
1280
+ if (!confirm)
1281
+ return finish(blocks, undefined, [
1282
+ "No changes made; add --confirm to run the action",
1283
+ ...(gate.mode === "preview" ? gate.help : []),
1284
+ ]);
1285
+ await checkWritePermission(`action/${resource.group ?? ""}/${kind}/${action}`, app, appNameArg, options);
1286
+ await runApplicationWrite("app actions run", [
1287
+ appNameArg,
1288
+ action,
1289
+ "--kind",
1290
+ kind,
1291
+ "--resource-name",
1292
+ resourceName,
1293
+ ...(resource.group ? ["--group", resource.group] : []),
1294
+ ...(resource.namespace ? ["--namespace", resource.namespace] : []),
1295
+ ...attributionArgs(),
1296
+ ], options, appNameArg);
1297
+ const current = await waitForAppState(appNameArg, timeout, options, (fresh) => fresh.status?.resources?.some((r) => r.group === resource.group &&
1298
+ r.kind === resource.kind &&
1299
+ r.namespace === resource.namespace &&
1300
+ r.name === resource.name &&
1301
+ r.health?.status === "Healthy") ?? false);
1302
+ const state = current.status?.resources?.find((r) => r.group === resource.group &&
1303
+ r.kind === resource.kind &&
1304
+ r.namespace === resource.namespace &&
1305
+ r.name === resource.name)?.health?.status ?? "unknown";
1306
+ return finish([...blocks, encodeObject({ result: "submitted", state })], undefined);
1192
1307
  }
1193
1308
  // ---------------------------------------------------------------- app writes
1194
1309
  function writeSpec(path, summary, positionals) {
@@ -1249,6 +1364,15 @@ function writeOutcome(phase, timedOut, completed) {
1249
1364
  return "waiting";
1250
1365
  return phase ?? "unknown";
1251
1366
  }
1367
+ async function waitForAppState(name, timeout, options, settled) {
1368
+ const deadline = Date.now() + timeout * 1000;
1369
+ let app = await getApp(name, options);
1370
+ while (!settled(app) && Date.now() < deadline) {
1371
+ await delay(Math.min(1000, Math.max(0, deadline - Date.now())));
1372
+ app = await getApp(name, options);
1373
+ }
1374
+ return app;
1375
+ }
1252
1376
  async function waitForWrite(name, timeout, options) {
1253
1377
  try {
1254
1378
  const result = await runArgocd("app wait", [name, "--operation", "--timeout", String(timeout)], { ...options, allowFailure: true, timeoutMs: (timeout + 10) * 1000 });
@@ -1412,6 +1536,61 @@ const SUBCOMMANDS = {
1412
1536
  refresh: { spec: APPS_REFRESH_SPEC, run: appsRefresh },
1413
1537
  "terminate-op": { spec: APPS_TERMINATE_SPEC, run: appsTerminateOp },
1414
1538
  };
1539
+ const APPS_AUTO_SYNC_SPEC = {
1540
+ path: "apps auto-sync",
1541
+ summary: "Preview and optionally enable or disable automated sync",
1542
+ positionals: [
1543
+ APP_ARG,
1544
+ { name: "mode", required: true, description: "on or off" },
1545
+ ],
1546
+ flags: [
1547
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1548
+ {
1549
+ name: "--timeout",
1550
+ kind: "value",
1551
+ valueName: "s",
1552
+ description: "Maximum wait in seconds (default 120)",
1553
+ },
1554
+ CONTEXT_FLAG,
1555
+ ],
1556
+ examples: ["argocd-axi apps auto-sync guestbook on --context staging"],
1557
+ };
1558
+ async function appsAutoSync(args) {
1559
+ const parsed = parseArgs(APPS_AUTO_SYNC_SPEC, args);
1560
+ const name = validateName("app", parsed.positionals[0]);
1561
+ const mode = parsed.positionals[1];
1562
+ if (mode !== "on" && mode !== "off")
1563
+ throw validationError("mode must be on or off");
1564
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1565
+ const gate = evaluateWriteGate({
1566
+ command: "apps auto-sync",
1567
+ context,
1568
+ execute: confirm,
1569
+ executeFlag: "--confirm",
1570
+ }, loadConfig().config);
1571
+ const options = { context: gate.context }, app = await getApp(name, options);
1572
+ const current = autoSyncSummary(app), blocks = [encodeObject({ autoSync: name, current, target: mode })];
1573
+ if (!confirm)
1574
+ return finish(blocks, undefined, [
1575
+ "No changes made; add --confirm to change automated sync",
1576
+ ...(gate.mode === "preview" ? gate.help : []),
1577
+ ]);
1578
+ await checkWritePermission("update", app, name, options);
1579
+ await runApplicationWrite("app set", [
1580
+ name,
1581
+ "--sync-policy",
1582
+ mode === "on" ? "automated" : "none",
1583
+ ...attributionArgs(),
1584
+ ], options, name);
1585
+ const observed = await waitForAppState(name, timeout, options, (fresh) => mode === "on"
1586
+ ? autoSyncSummary(fresh).startsWith("automated")
1587
+ : autoSyncSummary(fresh) === "manual");
1588
+ return finish([
1589
+ ...blocks,
1590
+ encodeObject({ result: "submitted", state: autoSyncSummary(observed) }),
1591
+ ], undefined);
1592
+ }
1593
+ SUBCOMMANDS["auto-sync"] = { spec: APPS_AUTO_SYNC_SPEC, run: appsAutoSync };
1415
1594
  export const APPS_SPECS = [
1416
1595
  APPS_LIST_SPEC,
1417
1596
  ...Object.values(SUBCOMMANDS).map((s) => s.spec),
@@ -0,0 +1,83 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { parseArgs } from "../args.js";
3
+ import { CODES, axiError } from "../errors.js";
4
+ import { encodeObject } from "../toon.js";
5
+ import { finish } from "./common.js";
6
+ import { lastSession, recordSession, sessionText, summarizeTranscript, } from "../sessionlog.js";
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+ function directory(path) {
11
+ try {
12
+ return realpathSync(path);
13
+ }
14
+ catch {
15
+ return path;
16
+ }
17
+ }
18
+ function display(summary) {
19
+ return summary ? sessionText(summary) : undefined;
20
+ }
21
+ const CONTEXT_SPEC = {
22
+ path: "context",
23
+ summary: "Show or record session memory",
24
+ positionals: [{ name: "action", required: false, description: "end" }],
25
+ flags: [],
26
+ examples: ["argocd-axi context", "argocd-axi context end"],
27
+ };
28
+ export async function contextCommand(args) {
29
+ const parsed = parseArgs(CONTEXT_SPEC, args);
30
+ const sub = parsed.positionals[0];
31
+ if (sub !== undefined && sub !== "end") {
32
+ throw axiError("unknown context action", CODES.VALIDATION_ERROR, [
33
+ "Run `argocd-axi context` or `argocd-axi context end`",
34
+ ]);
35
+ }
36
+ if (sub !== "end") {
37
+ const summary = display(lastSession(directory(process.cwd())));
38
+ return finish([encodeObject({ last_session: summary })], undefined);
39
+ }
40
+ if (process.stdin.isTTY)
41
+ throw axiError("context end reads a SessionEnd hook payload on stdin", CODES.VALIDATION_ERROR, ["It runs from the hook installed by `argocd-axi setup hooks`"]);
42
+ let payload;
43
+ try {
44
+ payload = JSON.parse(await new Promise((resolve, reject) => {
45
+ let text = "";
46
+ process.stdin.setEncoding("utf8");
47
+ process.stdin.on("data", (chunk) => (text += chunk));
48
+ process.stdin.on("end", () => resolve(text));
49
+ process.stdin.on("error", reject);
50
+ }));
51
+ }
52
+ catch {
53
+ return finish([encodeObject({ recorded: "nothing - stdin is not a hook payload" })], undefined);
54
+ }
55
+ if (!isRecord(payload) || typeof payload.transcript_path !== "string")
56
+ return finish([encodeObject({ recorded: "nothing - payload has no transcript_path" })], undefined);
57
+ let summary;
58
+ try {
59
+ summary = await summarizeTranscript(payload.transcript_path);
60
+ }
61
+ catch {
62
+ return finish([encodeObject({ recorded: "nothing - transcript could not be read" })], undefined);
63
+ }
64
+ if (!summary)
65
+ return finish([
66
+ encodeObject({
67
+ recorded: "nothing - no argocd-axi commands this session",
68
+ }),
69
+ ], undefined);
70
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
71
+ try {
72
+ if (!(await recordSession(directory(cwd), summary)))
73
+ return finish([encodeObject({ recorded: "nothing - session log is busy" })], undefined);
74
+ }
75
+ catch {
76
+ return finish([
77
+ encodeObject({
78
+ recorded: "nothing - session log could not be written",
79
+ }),
80
+ ], undefined);
81
+ }
82
+ return finish([encodeObject({ recorded: sessionText(summary) })], undefined);
83
+ }
@@ -150,12 +150,15 @@ const WRITABLE_ADD_SPEC = {
150
150
  positionals: [
151
151
  {
152
152
  name: "context",
153
- required: true,
153
+ required: false,
154
154
  description: "Configured Argo CD context",
155
155
  },
156
156
  ],
157
157
  flags: [],
158
- examples: ["argocd-axi ctx writable add staging"],
158
+ examples: [
159
+ "argocd-axi ctx writable add",
160
+ "argocd-axi ctx writable add staging",
161
+ ],
159
162
  };
160
163
  const WRITABLE_REMOVE_SPEC = {
161
164
  path: "ctx writable remove",
@@ -177,44 +180,92 @@ const WRITABLE_LIST_SPEC = {
177
180
  flags: [],
178
181
  examples: ["argocd-axi ctx writable list"],
179
182
  };
183
+ export async function readConfirmation(input, output, prompt) {
184
+ const rl = createInterface({ input, output });
185
+ const closed = new Promise((resolve) => rl.once("close", () => resolve("")));
186
+ try {
187
+ return await Promise.race([rl.question(prompt), closed]);
188
+ }
189
+ finally {
190
+ rl.close();
191
+ }
192
+ }
193
+ export async function confirmWritableContext(name, server, input, output) {
194
+ const answer = await readConfirmation(input, output, `Allow argocd-axi to sync, rollback, refresh, and terminate operations on context ${name} (${server})? [y/N] `);
195
+ return /^y(?:es)?$/i.test(answer.trim());
196
+ }
197
+ export async function contextDetails(contexts, lookup = async (name) => (await argocdJson("account get-user-info", ["-o", "json"], { context: name, timeoutMs: 1_500 })).username) {
198
+ const result = [];
199
+ for (let offset = 0; offset < contexts.length; offset += 3) {
200
+ const batch = await Promise.all(contexts.slice(offset, offset + 3).map(async (context) => ({
201
+ ...context,
202
+ username: (await lookup(context.name).catch(() => undefined)) ?? "unknown",
203
+ })));
204
+ result.push(...batch);
205
+ }
206
+ return result;
207
+ }
208
+ export function addWritableContextIfConfirmed(name, confirmed) {
209
+ return confirmed ? addWritableContext(name) : false;
210
+ }
211
+ export async function chooseContext(contexts, writable, input, output) {
212
+ output.write(contexts
213
+ .map((context, index) => `${index + 1}. ${context.name} - ${context.server} - ${context.username}${writable.includes(context.name) ? " (already writable)" : ""}`)
214
+ .join("\n") + "\n");
215
+ const selected = await readConfirmation(input, output, "Select a context by number: ");
216
+ const index = Number(selected);
217
+ const context = Number.isInteger(index) && index >= 1 ? contexts[index - 1] : undefined;
218
+ if (!context)
219
+ throw axiError("invalid context selection; no changes made", CODES.REFUSED);
220
+ return context.name;
221
+ }
180
222
  async function writableAdd(args) {
181
223
  const parsed = parseArgs(WRITABLE_ADD_SPEC, args);
182
- const name = validateContextName(parsed.positionals[0]);
224
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
225
+ throw axiError("writable context changes require an interactive terminal; run this command in a terminal", CODES.REFUSED);
183
226
  const listing = await listContexts();
184
227
  if (listing.notInstalled)
185
228
  throw listing.notInstalled;
186
229
  if (listing.unavailable) {
187
230
  throw axiError(`cannot list Argo CD contexts: ${listing.unavailable}`, CODES.NO_CONTEXT, LOGIN_HELP);
188
231
  }
189
- const match = listing.contexts.find((context) => context.name === name);
232
+ const writable = loadConfig().config.writableContexts;
233
+ let name = parsed.positionals[0]
234
+ ? validateContextName(parsed.positionals[0])
235
+ : undefined;
236
+ let username;
237
+ if (name === undefined) {
238
+ if (listing.contexts.length === 0)
239
+ throw axiError("no configured Argo CD contexts to select", CODES.NO_CONTEXT);
240
+ const details = await contextDetails(listing.contexts);
241
+ name = await chooseContext(details, writable, process.stdin, process.stdout);
242
+ username = details.find((context) => context.name === name)?.username;
243
+ }
244
+ const selectedName = name;
245
+ const match = listing.contexts.find((context) => context.name === selectedName);
190
246
  if (!match)
191
247
  throw axiError(`Argo CD context ${name} is not configured`, CODES.CONTEXT_NOT_FOUND);
192
- if (loadConfig().config.writableContexts.includes(name))
248
+ if (writable.includes(selectedName))
193
249
  return finish([
194
250
  encodeObject({
195
- writableContext: `${name} is already writable (no change)`,
251
+ writableContext: `${selectedName} is already writable (no change)`,
252
+ }),
253
+ ], undefined);
254
+ username ??= (await argocdJson("account get-user-info", ["-o", "json"], { context: selectedName })).username;
255
+ process.stdout.write(`Context: ${selectedName}\nServer: ${match.server}\nUser: ${username ?? "unknown"}\n`);
256
+ const confirmed = await confirmWritableContext(selectedName, match.server, process.stdin, process.stdout);
257
+ const changed = addWritableContextIfConfirmed(selectedName, confirmed);
258
+ if (!confirmed)
259
+ return finish([
260
+ encodeObject({
261
+ writableContext: `${selectedName} remains read-only (no change)`,
196
262
  }),
197
263
  ], undefined);
198
- if (!process.stdin.isTTY || !process.stdout.isTTY)
199
- throw axiError("writable context changes require an interactive terminal", CODES.REFUSED);
200
- const info = await argocdJson("account get-user-info", ["-o", "json"], { context: name });
201
- process.stdout.write(`Context: ${name}\nServer: ${match.server}\nUser: ${info.username ?? "unknown"}\nType ${name} to allow writes: `);
202
- const rl = createInterface({ input: process.stdin, output: process.stdout });
203
- let answer;
204
- try {
205
- answer = await rl.question("");
206
- }
207
- finally {
208
- rl.close();
209
- }
210
- if (answer !== name)
211
- throw axiError("confirmation did not match; no changes made", CODES.REFUSED);
212
- const changed = addWritableContext(name);
213
264
  return finish([
214
265
  encodeObject({
215
266
  writableContext: changed
216
- ? `added ${name}`
217
- : `${name} is already writable (no change)`,
267
+ ? `added ${selectedName}`
268
+ : `${selectedName} is already writable (no change)`,
218
269
  }),
219
270
  ], undefined);
220
271
  }
@@ -6,14 +6,16 @@ import { parseArgs, renderCommandHelp } from "../args.js";
6
6
  import { CODES, axiError, validationError } from "../errors.js";
7
7
  import { encodeObject } from "../toon.js";
8
8
  import { finish } from "./common.js";
9
+ import { installSessionEndHook, sessionEndHookStatus, uninstallSessionEndHook, } from "../session-hook.js";
9
10
  export const SETUP_HOOKS_SPEC = {
10
11
  path: "setup hooks",
11
- summary: "Install or repair SessionStart hooks for Claude Code, Codex and OpenCode",
12
+ summary: "Install, inspect or remove agent session hooks",
12
13
  positionals: [],
13
14
  flags: [],
14
15
  examples: ["argocd-axi setup hooks"],
15
16
  notes: [
16
17
  "The hook runs `argocd-axi-ctx`, which is local only: current context, server and CLI version, with no network call, no SSO refresh and no credential read",
18
+ "SessionEnd records command names, counts and the most-used --context value only; setup status and setup uninstall inspect or remove that Claude Code hook",
17
19
  "Intended for global installs; repeated runs are no-ops",
18
20
  ],
19
21
  };
@@ -29,16 +31,31 @@ function ctxEntrypoint() {
29
31
  : join(here, "..", "..", "bin", "argocd-axi-ctx.ts");
30
32
  }
31
33
  export async function setupCommand(args) {
32
- if (args[0] !== "hooks") {
34
+ const action = args[0];
35
+ if (action !== "hooks" && action !== "status" && action !== "uninstall") {
33
36
  if (args.includes("--help"))
34
37
  return renderCommandHelp(SETUP_HOOKS_SPEC);
35
38
  throw validationError("unknown setup action", [
36
- "Run `argocd-axi setup hooks`",
39
+ "Run `argocd-axi setup hooks`, `argocd-axi setup status` or `argocd-axi setup uninstall`",
37
40
  ]);
38
41
  }
39
42
  if (args.includes("--help"))
40
43
  return renderCommandHelp(SETUP_HOOKS_SPEC);
41
44
  parseArgs(SETUP_HOOKS_SPEC, args.slice(1));
45
+ if (action === "status")
46
+ return finish([
47
+ encodeObject({
48
+ sessionEnd: sessionEndHookStatus() ? "installed" : "not installed",
49
+ }),
50
+ ], undefined);
51
+ if (action === "uninstall")
52
+ return finish([
53
+ encodeObject({
54
+ sessionEnd: uninstallSessionEndHook()
55
+ ? "removed"
56
+ : "not installed (no-op)",
57
+ }),
58
+ ], undefined);
42
59
  // An npx (or pnpm dlx, bunx) run lives in a cache that later sessions may
43
60
  // not have on PATH or may have evicted, so a hook pointing at it would
44
61
  // silently stop working. Require a persistent install instead.
@@ -56,6 +73,12 @@ export async function setupCommand(args) {
56
73
  distEntrypoints: ["dist/bin/argocd-axi-ctx.js"],
57
74
  onError: (message) => errors.push(message),
58
75
  });
76
+ try {
77
+ installSessionEndHook();
78
+ }
79
+ catch (error) {
80
+ errors.push(error instanceof Error ? error.message : String(error));
81
+ }
59
82
  if (errors.length > 0) {
60
83
  throw axiError(`hook installation reported problems: ${errors.join("; ")}`, CODES.UPSTREAM_ERROR, ["Re-run `argocd-axi setup hooks` after fixing the paths above"]);
61
84
  }
package/dist/src/help.js CHANGED
@@ -4,6 +4,8 @@ export const DESCRIPTION = "Argo CD for agents: read operations plus opt-in writ
4
4
  export const COMMAND_LINES = [
5
5
  "(no command): live overview - context, server, user, apps needing attention with the reason, running operations",
6
6
  "ctx [--context <c>]: current context, server and CLI version, local only (what the session hook prints)",
7
+ "context: last session's argocd-axi command summary for this directory",
8
+ "context end: record the current Claude Code session summary (SessionEnd hook)",
7
9
  "ctx use <context>: switch the argocd CLI's current context (local config only)",
8
10
  "auth guide [--context <c>]: offline human login and agent token setup instructions",
9
11
  "whoami: user, issuer and groups of the current credential",
@@ -16,15 +18,17 @@ export const COMMAND_LINES = [
16
18
  "apps rollback <app> <history-id> [--confirm] [--timeout <s>]: preview revision, then optionally roll back",
17
19
  "apps refresh <app> [--hard] [--confirm]: preview, then optionally refresh one app",
18
20
  "apps terminate-op <app> [--confirm] [--timeout <s>]: preview, then optionally stop a running operation",
21
+ "apps auto-sync <app> <on|off> [--confirm]: preview, then optionally change automated sync",
22
+ "apps actions run <app> <action> --kind <kind> --name <resource> [--confirm]: preview, then optionally run one listed resource action",
19
23
  "apps history <app>: deployments, newest first",
20
24
  "apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted",
21
25
  "apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows",
22
26
  "apps wait <app> [--sync --health --operation] [--timeout <s>]: bounded wait, returns the state either way",
23
- "apps actions <app>: resource actions available (listing only)",
27
+ "apps actions <app>: list available resource actions",
24
28
  "projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now",
25
29
  "appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate",
26
30
  "repos | clusters: repositories and destination clusters with connection status",
27
- "setup hooks: install the SessionStart hook (runs the offline ctx view)",
31
+ "setup hooks | status | uninstall: manage the Claude Code SessionEnd memory hook; setup hooks also installs SessionStart hooks",
28
32
  ];
29
33
  export const TOP_HELP = `${renderBlocks([
30
34
  "usage: argocd-axi <command> [args] [flags]",
@@ -42,7 +46,7 @@ export const TOP_HELP = `${renderBlocks([
42
46
  "argocd-axi apps diff payments-api",
43
47
  ]),
44
48
  renderHelpBlock("notes", [
45
- "Read-only except opt-in writes on writable contexts: apps sync, rollback, refresh and terminate-op require --confirm; sync never prunes unless --prune is given",
49
+ "Writes on writable contexts require --confirm: apps sync, rollback, refresh, terminate-op, auto-sync and actions run; sync never prunes unless --prune is given",
46
50
  "Output is TOON on stdout, errors included; exit 0 = success (an unhealthy app is data, not an error), 1 = error, 2 = usage",
47
51
  "Secret-looking values are redacted and counted; no flag reveals them",
48
52
  "Uses your existing argocd CLI session or ARGOCD_AUTH_TOKEN; never logs in, never reads the argocd config file",
@@ -50,6 +50,18 @@ export const OPERATIONS = {
50
50
  network: true,
51
51
  timeoutMs: READ_TIMEOUT_MS,
52
52
  },
53
+ "app actions run": {
54
+ argv: ["app", "actions", "run"],
55
+ tier: "write",
56
+ network: true,
57
+ timeoutMs: READ_TIMEOUT_MS,
58
+ },
59
+ "app set": {
60
+ argv: ["app", "set"],
61
+ tier: "write",
62
+ network: true,
63
+ timeoutMs: READ_TIMEOUT_MS,
64
+ },
53
65
  "app resources": {
54
66
  argv: ["app", "resources"],
55
67
  tier: "read",
@@ -190,20 +202,11 @@ export const FORBIDDEN_ARGS = [
190
202
  "--port-forward",
191
203
  "--prompts-enabled",
192
204
  ];
193
- const PLANNED = "planned for the write tier, behind preview-by-default and the writable-contexts allowlist; not in v1 (see ROADMAP.md)";
194
205
  /**
195
206
  * Writes the design names for the next tier (decision A2): known verbs that
196
207
  * answer REFUSED with the raw argocd route a human can take today.
197
208
  */
198
- export const PLANNED_WRITES = {
199
- "apps action": {
200
- reason: `running resource actions is ${PLANNED}`,
201
- help: [
202
- "List available actions with `argocd-axi apps actions <app>`",
203
- "A human can run one with the raw CLI: `argocd app actions run <app> <action> --kind <kind>`",
204
- ],
205
- },
206
- };
209
+ export const PLANNED_WRITES = {};
207
210
  const HUMAN_ONLY = "A human runs this with the raw argocd CLI";
208
211
  /**
209
212
  * Known verbs that argocd-axi never wraps (design decisions S7 and A2), each
@@ -0,0 +1,117 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ const path = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
6
+ const command = "argocd-axi context end";
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+ function settings() {
11
+ const file = path();
12
+ if (!existsSync(file))
13
+ return {};
14
+ const value = JSON.parse(readFileSync(file, "utf8"));
15
+ if (!isRecord(value))
16
+ throw new Error(`${file} is not a JSON object`);
17
+ return value;
18
+ }
19
+ function writeSettings(value) {
20
+ const file = path();
21
+ mkdirSync(join(file, ".."), { recursive: true });
22
+ const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
23
+ const temporary = `${file}.${randomUUID()}.tmp`;
24
+ try {
25
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
26
+ chmodSync(temporary, mode);
27
+ renameSync(temporary, file);
28
+ }
29
+ catch (error) {
30
+ try {
31
+ unlinkSync(temporary);
32
+ }
33
+ catch {
34
+ /* No temporary file to remove. */
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+ export function installSessionEndHook() {
40
+ const value = settings();
41
+ const hooks = isRecord(value.hooks) ? value.hooks : {};
42
+ value.hooks = hooks;
43
+ const groups = Array.isArray(hooks.SessionEnd) ? hooks.SessionEnd : [];
44
+ const next = [];
45
+ let installed = false;
46
+ for (const group of groups) {
47
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
48
+ next.push(group);
49
+ continue;
50
+ }
51
+ const kept = group.hooks.filter((entry) => {
52
+ const ours = isRecord(entry) &&
53
+ (entry.command === command ||
54
+ (typeof entry.command === "string" &&
55
+ entry.command.endsWith("/argocd-axi context end")));
56
+ if (ours && !installed) {
57
+ installed = true;
58
+ return false;
59
+ }
60
+ return !ours;
61
+ });
62
+ if (kept.length)
63
+ next.push({ ...group, hooks: kept });
64
+ }
65
+ next.push({
66
+ matcher: "",
67
+ hooks: [{ type: "command", command, timeout: 30 }],
68
+ });
69
+ hooks.SessionEnd = next;
70
+ writeSettings(value);
71
+ }
72
+ export function sessionEndHookStatus() {
73
+ try {
74
+ const hooks = settings().hooks;
75
+ const groups = isRecord(hooks) ? hooks.SessionEnd : undefined;
76
+ return (Array.isArray(groups) &&
77
+ groups.some((group) => isRecord(group) &&
78
+ Array.isArray(group.hooks) &&
79
+ group.hooks.some((entry) => isRecord(entry) &&
80
+ (entry.command === command ||
81
+ (typeof entry.command === "string" &&
82
+ entry.command.endsWith("/argocd-axi context end"))))));
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ export function uninstallSessionEndHook() {
89
+ const value = settings();
90
+ const hooks = value.hooks;
91
+ if (!isRecord(hooks) || !Array.isArray(hooks.SessionEnd))
92
+ return false;
93
+ let removed = false;
94
+ const next = hooks.SessionEnd.flatMap((group) => {
95
+ if (!isRecord(group) || !Array.isArray(group.hooks))
96
+ return [group];
97
+ const entries = group.hooks.filter((entry) => {
98
+ const ours = isRecord(entry) &&
99
+ (entry.command === command ||
100
+ (typeof entry.command === "string" &&
101
+ entry.command.endsWith("/argocd-axi context end")));
102
+ removed ||= ours;
103
+ return !ours;
104
+ });
105
+ return entries.length ? [{ ...group, hooks: entries }] : [];
106
+ });
107
+ if (!removed)
108
+ return false;
109
+ if (next.length)
110
+ hooks.SessionEnd = next;
111
+ else
112
+ delete hooks.SessionEnd;
113
+ if (Object.keys(hooks).length === 0)
114
+ delete value.hooks;
115
+ writeSettings(value);
116
+ return true;
117
+ }
@@ -0,0 +1,347 @@
1
+ import { chmodSync, closeSync, createReadStream, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { configPath } from "./config.js";
6
+ const MAX_DIRECTORIES = 100;
7
+ const LOCK_WAIT_MS = 2_000;
8
+ const STALE_LOCK_MS = 30_000;
9
+ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
10
+ const TOOL = "argocd-axi";
11
+ const COMMANDS = new Set([
12
+ "apps",
13
+ "projects",
14
+ "appsets",
15
+ "repos",
16
+ "clusters",
17
+ "ctx",
18
+ "auth",
19
+ "whoami",
20
+ "can-i",
21
+ "setup",
22
+ "context",
23
+ ]);
24
+ const CONTEXTS = new Set([
25
+ "apps",
26
+ "projects",
27
+ "appsets",
28
+ "repos",
29
+ "clusters",
30
+ "ctx",
31
+ "auth",
32
+ "whoami",
33
+ "can-i",
34
+ ]);
35
+ /** Split shell words without retaining anything in the persisted result. */
36
+ function words(command) {
37
+ const result = [];
38
+ let token = "";
39
+ let quote = "";
40
+ let started = false;
41
+ const end = () => {
42
+ if (started)
43
+ result.push(token);
44
+ token = "";
45
+ started = false;
46
+ };
47
+ for (let i = 0; i < command.length; i++) {
48
+ const c = command[i];
49
+ if (quote) {
50
+ if (c === quote)
51
+ quote = "";
52
+ else if (c === "\\" && quote === '"' && i + 1 < command.length)
53
+ token += command[++i];
54
+ else
55
+ token += c;
56
+ }
57
+ else if (c === "'" || c === '"') {
58
+ quote = c;
59
+ started = true;
60
+ }
61
+ else if (c === "\\" && i + 1 < command.length) {
62
+ token += command[++i];
63
+ started = true;
64
+ }
65
+ else if (/\s|[;|&()`]/.test(c))
66
+ end();
67
+ else {
68
+ token += c;
69
+ started = true;
70
+ }
71
+ }
72
+ end();
73
+ return result;
74
+ }
75
+ function commandParts(line) {
76
+ const parts = [];
77
+ let quote = "";
78
+ let start = 0;
79
+ for (let i = 0; i < line.length; i++) {
80
+ const c = line[i];
81
+ if (quote) {
82
+ if (c === "\\" && quote === '"')
83
+ i++;
84
+ else if (c === quote)
85
+ quote = "";
86
+ }
87
+ else if (c === "\\")
88
+ i++;
89
+ else if (c === "'" || c === '"')
90
+ quote = c;
91
+ else if (/[;|&\n]/.test(c)) {
92
+ parts.push(line.slice(start, i));
93
+ start = i + 1;
94
+ }
95
+ }
96
+ parts.push(line.slice(start));
97
+ return parts;
98
+ }
99
+ function commandCalls(line) {
100
+ const calls = [];
101
+ for (const part of commandParts(line)) {
102
+ const args = words(part);
103
+ const index = args.findIndex((word) => word.split("/").pop()?.replace(/\.js$/, "") === TOOL);
104
+ if (index < 0)
105
+ continue;
106
+ const argsAfter = args.slice(index + 1);
107
+ const command = argsAfter.find((arg) => !arg.startsWith("-"));
108
+ if (!command || !COMMANDS.has(command))
109
+ continue;
110
+ const contextArg = argsAfter.findIndex((arg) => arg === "--context");
111
+ const contextValue = contextArg >= 0
112
+ ? argsAfter[contextArg + 1]
113
+ : argsAfter.find((arg) => arg.startsWith("--context="))?.slice(10);
114
+ calls.push({
115
+ command,
116
+ ...(contextValue && CONTEXTS.has(command.split(" ")[0])
117
+ ? { context: contextValue }
118
+ : {}),
119
+ });
120
+ }
121
+ return calls;
122
+ }
123
+ function isRecord(value) {
124
+ return value !== null && typeof value === "object" && !Array.isArray(value);
125
+ }
126
+ function* toolCommands(value) {
127
+ if (Array.isArray(value)) {
128
+ for (const item of value)
129
+ yield* toolCommands(item);
130
+ return;
131
+ }
132
+ if (!isRecord(value))
133
+ return;
134
+ if (value.type === "tool_use") {
135
+ if (isRecord(value.input) && typeof value.input.command === "string")
136
+ yield value.input.command;
137
+ return;
138
+ }
139
+ for (const key of ["message", "content"])
140
+ if (key in value)
141
+ yield* toolCommands(value[key]);
142
+ }
143
+ export async function summarizeTranscript(path) {
144
+ if (statSync(path).size > MAX_TRANSCRIPT_BYTES)
145
+ return undefined;
146
+ const counts = {};
147
+ const contexts = [];
148
+ const lines = createInterface({
149
+ input: createReadStream(path, { encoding: "utf8" }),
150
+ crlfDelay: Infinity,
151
+ });
152
+ for await (const line of lines) {
153
+ if (!line.includes(TOOL))
154
+ continue;
155
+ let entry;
156
+ try {
157
+ entry = JSON.parse(line);
158
+ }
159
+ catch {
160
+ continue;
161
+ }
162
+ for (const commandLine of toolCommands(entry))
163
+ for (const call of commandCalls(commandLine)) {
164
+ counts[call.command] = (counts[call.command] ?? 0) + 1;
165
+ if (call.context)
166
+ contexts.push(call.context);
167
+ }
168
+ }
169
+ if (Object.keys(counts).length === 0)
170
+ return undefined;
171
+ const contextCounts = new Map();
172
+ for (const context of contexts)
173
+ contextCounts.set(context, (contextCounts.get(context) ?? 0) + 1);
174
+ const context = [...contextCounts].sort((a, b) => b[1] - a[1])[0]?.[0];
175
+ return { commands: counts, ...(context ? { context } : {}) };
176
+ }
177
+ const sessionsPath = () => join(dirname(configPath()), "sessions.json");
178
+ function isSessionSummary(value) {
179
+ return (isRecord(value) &&
180
+ isRecord(value.commands) &&
181
+ Object.entries(value.commands).every(([name, count]) => COMMANDS.has(name) &&
182
+ typeof count === "number" &&
183
+ Number.isSafeInteger(count) &&
184
+ count > 0) &&
185
+ (value.context === undefined ||
186
+ (typeof value.context === "string" && !/[\r\n]/.test(value.context))));
187
+ }
188
+ function readSessions() {
189
+ try {
190
+ const parsed = JSON.parse(readFileSync(sessionsPath(), "utf8"));
191
+ if (isRecord(parsed) && isRecord(parsed.directories))
192
+ return Object.fromEntries(Object.entries(parsed.directories).filter((entry) => isSessionSummary(entry[1])));
193
+ }
194
+ catch {
195
+ /* Missing or corrupt memory is optional. */
196
+ }
197
+ return {};
198
+ }
199
+ function processAlive(pid) {
200
+ try {
201
+ process.kill(pid, 0);
202
+ return true;
203
+ }
204
+ catch (error) {
205
+ return errorCode(error) !== "ESRCH";
206
+ }
207
+ }
208
+ function lockPid(token) {
209
+ const pid = Number(token.trim().split(/\s+/, 1)[0]);
210
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
211
+ }
212
+ function errorCode(error) {
213
+ return error &&
214
+ typeof error === "object" &&
215
+ "code" in error &&
216
+ typeof error.code === "string"
217
+ ? error.code
218
+ : undefined;
219
+ }
220
+ async function acquireLock(path) {
221
+ const lockPath = `${path}.lock`;
222
+ const deadline = Date.now() + LOCK_WAIT_MS;
223
+ while (Date.now() < deadline) {
224
+ try {
225
+ mkdirSync(lockPath, 0o700);
226
+ const ownerPath = join(lockPath, `owner-${randomUUID()}`);
227
+ const token = `${process.pid} ${randomUUID()}\n`;
228
+ let fd;
229
+ try {
230
+ fd = openSync(ownerPath, "wx", 0o600);
231
+ }
232
+ catch {
233
+ rmdirSync(lockPath);
234
+ return undefined;
235
+ }
236
+ try {
237
+ writeFileSync(fd, token);
238
+ return { fd, path: ownerPath, token };
239
+ }
240
+ catch {
241
+ closeSync(fd);
242
+ try {
243
+ unlinkSync(ownerPath);
244
+ rmdirSync(lockPath);
245
+ }
246
+ catch {
247
+ /* The lock may already be gone. */
248
+ }
249
+ return undefined;
250
+ }
251
+ }
252
+ catch (error) {
253
+ if (errorCode(error) !== "EEXIST")
254
+ return undefined;
255
+ if (reclaimStaleLock(lockPath))
256
+ continue;
257
+ await new Promise((resolve) => setTimeout(resolve, 25));
258
+ }
259
+ }
260
+ return undefined;
261
+ }
262
+ function reclaimStaleLock(lockPath) {
263
+ try {
264
+ const owners = readdirSync(lockPath);
265
+ if (owners.length === 0) {
266
+ if (Date.now() - statSync(lockPath).mtimeMs <= STALE_LOCK_MS)
267
+ return false;
268
+ rmdirSync(lockPath);
269
+ return true;
270
+ }
271
+ for (const ownerFile of owners) {
272
+ const ownerPath = join(lockPath, ownerFile);
273
+ const info = statSync(ownerPath);
274
+ const owner = readFileSync(ownerPath, "utf8");
275
+ const pid = lockPid(owner);
276
+ if (Date.now() - info.mtimeMs <= STALE_LOCK_MS ||
277
+ pid === undefined ||
278
+ processAlive(pid) ||
279
+ readFileSync(ownerPath, "utf8") !== owner)
280
+ continue;
281
+ unlinkSync(ownerPath);
282
+ rmdirSync(lockPath);
283
+ return true;
284
+ }
285
+ }
286
+ catch {
287
+ return false;
288
+ }
289
+ return false;
290
+ }
291
+ export function releaseSessionLock(lock) {
292
+ closeSync(lock.fd);
293
+ try {
294
+ if (readFileSync(lock.path, "utf8") === lock.token) {
295
+ unlinkSync(lock.path);
296
+ rmdirSync(dirname(lock.path));
297
+ }
298
+ }
299
+ catch {
300
+ /* A successor owns the lock, or it is already gone. */
301
+ }
302
+ }
303
+ export async function recordSession(directory, summary) {
304
+ const path = sessionsPath();
305
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
306
+ const lock = await acquireLock(path);
307
+ if (lock === undefined)
308
+ return false;
309
+ try {
310
+ const directories = readSessions();
311
+ delete directories[directory];
312
+ directories[directory] = summary;
313
+ for (const old of Object.keys(directories).slice(0, Math.max(0, Object.keys(directories).length - MAX_DIRECTORIES)))
314
+ delete directories[old];
315
+ const temporary = `${path}.${randomUUID()}.tmp`;
316
+ try {
317
+ writeFileSync(temporary, `${JSON.stringify({ directories }, null, 2)}\n`, {
318
+ mode: 0o600,
319
+ });
320
+ chmodSync(temporary, 0o600);
321
+ renameSync(temporary, path);
322
+ }
323
+ catch (error) {
324
+ try {
325
+ unlinkSync(temporary);
326
+ }
327
+ catch {
328
+ /* No temporary file to remove. */
329
+ }
330
+ throw error;
331
+ }
332
+ return true;
333
+ }
334
+ finally {
335
+ releaseSessionLock(lock);
336
+ }
337
+ }
338
+ export function lastSession(directory) {
339
+ return readSessions()[directory];
340
+ }
341
+ export function sessionText(summary) {
342
+ const commands = Object.entries(summary.commands)
343
+ .sort((a, b) => b[1] - a[1])
344
+ .map(([name, count]) => `${name} ${count}`)
345
+ .join("; ");
346
+ return `${commands}${summary.context ? ` - context ${JSON.stringify(summary.context)}` : ""}`;
347
+ }
package/dist/src/skill.js CHANGED
@@ -33,7 +33,7 @@ For anything argocd-axi does not cover, the raw \`argocd\` CLI is the fallback.
33
33
 
34
34
  ## Setup
35
35
 
36
- Run \`npx -y argocd-axi auth guide\` for offline instructions. A human operator logs in interactively with \`argocd login <server> --sso --grpc-web\`; agents and CI use \`ARGOCD_SERVER\` and \`ARGOCD_AUTH_TOKEN\` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with \`npx -y argocd-axi whoami\`. To enable writes on a context, a human runs \`argocd-axi ctx writable add <context>\` in a terminal and confirms the context name.
36
+ Run \`npx -y argocd-axi auth guide\` for offline instructions. A human operator logs in interactively with \`argocd login <server> --sso --grpc-web\`; agents and CI use \`ARGOCD_SERVER\` and \`ARGOCD_AUTH_TOKEN\` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with \`npx -y argocd-axi whoami\`. See the README's writable-context instructions before enabling writes.
37
37
 
38
38
  ## Workflow
39
39
 
@@ -1,3 +1,3 @@
1
1
  // Leaf module: node builtins only, so the bin fast path can answer
2
2
  // `--version` without loading the command graph.
3
- export const VERSION = "0.1.5"; // x-release-please-version
3
+ export const VERSION = "0.1.7"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argocd-axi",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Agent-ergonomic Argo CD CLI with opt-in writes - AXI-compliant argocd wrapper with token-efficient TOON output",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@ For anything argocd-axi does not cover, the raw `argocd` CLI is the fallback.
20
20
 
21
21
  ## Setup
22
22
 
23
- Run `npx -y argocd-axi auth guide` for offline instructions. A human operator logs in interactively with `argocd login <server> --sso --grpc-web`; agents and CI use `ARGOCD_SERVER` and `ARGOCD_AUTH_TOKEN` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with `npx -y argocd-axi whoami`. To enable writes on a context, a human runs `argocd-axi ctx writable add <context>` in a terminal and confirms the context name.
23
+ Run `npx -y argocd-axi auth guide` for offline instructions. A human operator logs in interactively with `argocd login <server> --sso --grpc-web`; agents and CI use `ARGOCD_SERVER` and `ARGOCD_AUTH_TOKEN` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with `npx -y argocd-axi whoami`. See the README's writable-context instructions before enabling writes.
24
24
 
25
25
  ## Workflow
26
26
 
@@ -34,9 +34,11 @@ Run `npx -y argocd-axi auth guide` for offline instructions. A human operator lo
34
34
  ## Commands
35
35
 
36
36
  ```
37
- commands[23]:
37
+ commands[27]:
38
38
  (no command): live overview - context, server, user, apps needing attention with the reason, running operations
39
39
  ctx [--context <c>]: current context, server and CLI version, local only (what the session hook prints)
40
+ context: last session's argocd-axi command summary for this directory
41
+ context end: record the current Claude Code session summary (SessionEnd hook)
40
42
  ctx use <context>: switch the argocd CLI's current context (local config only)
41
43
  auth guide [--context <c>]: offline human login and agent token setup instructions
42
44
  whoami: user, issuer and groups of the current credential
@@ -49,15 +51,17 @@ commands[23]:
49
51
  apps rollback <app> <history-id> [--confirm] [--timeout <s>]: preview revision, then optionally roll back
50
52
  apps refresh <app> [--hard] [--confirm]: preview, then optionally refresh one app
51
53
  apps terminate-op <app> [--confirm] [--timeout <s>]: preview, then optionally stop a running operation
54
+ apps auto-sync <app> <on|off> [--confirm]: preview, then optionally change automated sync
55
+ apps actions run <app> <action> --kind <kind> --name <resource> [--confirm]: preview, then optionally run one listed resource action
52
56
  apps history <app>: deployments, newest first
53
57
  apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted
54
58
  apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows
55
59
  apps wait <app> [--sync --health --operation] [--timeout <s>]: bounded wait, returns the state either way
56
- apps actions <app>: resource actions available (listing only)
60
+ apps actions <app>: list available resource actions
57
61
  projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now
58
62
  appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate
59
63
  repos | clusters: repositories and destination clusters with connection status
60
- setup hooks: install the SessionStart hook (runs the offline ctx view)
64
+ setup hooks | status | uninstall: manage the Claude Code SessionEnd memory hook; setup hooks also installs SessionStart hooks
61
65
  ```
62
66
 
63
67
  Run `npx -y argocd-axi --help` for global flags, or `npx -y argocd-axi <command> --help` for per-command usage.