argocd-axi 0.1.6 → 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
@@ -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.
@@ -156,7 +162,8 @@ argocd-axi apps actions run <app> <action> --kind <k> --name <resource> [--confi
156
162
  argocd-axi projects | projects view <p> | projects windows <p>
157
163
  argocd-axi appsets | appsets view <name> | appsets generate <file>
158
164
  argocd-axi repos | clusters
159
- argocd-axi setup hooks
165
+ argocd-axi context [end]
166
+ argocd-axi setup hooks | status | uninstall
160
167
  argocd-axi update [--check]
161
168
  ```
162
169
 
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)) {
@@ -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
+ }
@@ -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",
@@ -26,7 +28,7 @@ export const COMMAND_LINES = [
26
28
  "projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now",
27
29
  "appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate",
28
30
  "repos | clusters: repositories and destination clusters with connection status",
29
- "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",
30
32
  ];
31
33
  export const TOP_HELP = `${renderBlocks([
32
34
  "usage: argocd-axi <command> [args] [flags]",
@@ -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
+ }
@@ -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.6"; // 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.6",
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",
@@ -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[25]:
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
@@ -59,7 +61,7 @@ commands[25]:
59
61
  projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now
60
62
  appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate
61
63
  repos | clusters: repositories and destination clusters with connection status
62
- 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
63
65
  ```
64
66
 
65
67
  Run `npx -y argocd-axi --help` for global flags, or `npx -y argocd-axi <command> --help` for per-command usage.