Skip to content

Commit 0f5a5a4

Browse files
KJ-11claude
andcommitted
feat(hooks): nudge CLI upgrade when a newer stable release is cached
SessionStart now compares `composio version` against the CLI's own 24h-throttled release cache (~/.composio/update-check.json) and appends "A newer Composio CLI (X.Y.Z) is available — run `composio upgrade`." to the cli_status line. Local file read only — never the network (PRDE-1144; `composio version --check` from ComposioHQ/composio#3944 was ruled out for the hook because it can do a synchronous fetch on a stale cache, which could blow the 8s hook budget). Only a plain X.Y.Z install strictly older than a plain X.Y.Z latestVersion nudges; prereleases on either side, a missing or malformed cache, or an unparseable version all degrade to silence. Tests pin HOME so the cache read stays hermetic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9d2590c commit 0f5a5a4

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

plugins/composio/.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "composio",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Your agent decides what to do — Composio handles the rest. Use just-in-time tool calls across 1,000+ apps with managed auth through the hosted app or local CLI.",
55
"author": {
66
"name": "Composio",

plugins/composio/hooks/session-start.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ else
3232
'"authenticated"[[:space:]]*:[[:space:]]*false|not[[:space:]-]+logged[[:space:]-]+in'; then
3333
cli_status="The local Composio CLI is available and signed in."
3434
fi
35+
36+
# Upgrade nudge: read the CLI's own 24h-throttled release cache — a local
37+
# file only, never the network. Nudge only for a plain X.Y.Z install that is
38+
# strictly older than a plain X.Y.Z latestVersion (the anchored closing quote
39+
# rejects prereleases instead of truncating them); anything missing or
40+
# malformed degrades to silence.
41+
update_cache="${HOME:-}/.composio/update-check.json"
42+
if [ -f "$update_cache" ]; then
43+
latest="$(sed -n 's/.*"latestVersion"[[:space:]]*:[[:space:]]*"\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\)".*/\1/p' "$update_cache" 2>/dev/null | head -n 1)"
44+
installed="$(composio version 2>/dev/null | tail -n 1 | tr -d '[:space:]')"
45+
if [ -n "$latest" ] && [ "$installed" != "$latest" ] \
46+
&& printf '%s' "$installed" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \
47+
&& [ "$(printf '%s\n%s\n' "$installed" "$latest" \
48+
| sort -t. -k1,1n -k2,2n -k3,3n | head -n 1)" = "$installed" ]; then
49+
cli_status="${cli_status} A newer Composio CLI (${latest}) is available — run \`composio upgrade\`."
50+
fi
51+
fi
3552
fi
3653

3754
[ -n "$cache_pid" ] && wait "$cache_pid" 2>/dev/null

tests/test_package.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ def test_readme_documents_the_combined_local_flow(self):
169169

170170
class HookBehaviorTests(unittest.TestCase):
171171
def run_hook(self, script, payload, tmpdir, path=None):
172-
env = dict(os.environ, TMPDIR=tmpdir)
172+
# HOME is pinned to the sandbox so the upgrade-nudge cache read never
173+
# sees the developer's real ~/.composio/update-check.json.
174+
env = dict(os.environ, TMPDIR=tmpdir, HOME=tmpdir)
173175
if path is not None:
174176
env["PATH"] = path
175177
return subprocess.run(
@@ -182,11 +184,20 @@ def run_hook(self, script, payload, tmpdir, path=None):
182184
check=False,
183185
)
184186

185-
def fake_composio(self, tmpdir, stdout="", exit_code=0, toolkits=None):
187+
def fake_composio(self, tmpdir, stdout="", exit_code=0, toolkits=None, version=None):
186188
bindir = pathlib.Path(tmpdir) / "bin"
187189
bindir.mkdir(exist_ok=True)
188190
script = bindir / "composio"
189191
lines = ["#!/usr/bin/env bash"]
192+
if version is not None:
193+
lines.extend(
194+
[
195+
'if [ "$1" = "version" ]; then',
196+
f" printf '%s\\n' {shlex.quote(version)}",
197+
" exit 0",
198+
"fi",
199+
]
200+
)
190201
if toolkits is not None:
191202
lines.extend(
192203
[
@@ -246,6 +257,61 @@ def test_session_start_offers_cli_install_in_terminal_context(self):
246257
self.assertNotIn("composio execute", context)
247258
self.assertNotIn("composio search", context)
248259

260+
NUDGE = "newer Composio CLI"
261+
262+
def write_update_cache(self, tmpdir, content):
263+
cache_dir = pathlib.Path(tmpdir) / ".composio"
264+
cache_dir.mkdir(exist_ok=True)
265+
(cache_dir / "update-check.json").write_text(content, encoding="utf-8")
266+
267+
def session_context(self, tmpdir, path):
268+
result = self.run_hook(
269+
"session-start.sh", {"hook_event_name": "SessionStart"}, tmpdir, path
270+
)
271+
self.assertEqual(0, result.returncode, result.stderr)
272+
return json.loads(result.stdout)["hookSpecificOutput"]["additionalContext"]
273+
274+
def test_session_start_nudges_upgrade_for_older_stable_cli(self):
275+
# SessionStart compares `composio version` against the CLI's own
276+
# ~/.composio/update-check.json release cache (local file, no network).
277+
with tempfile.TemporaryDirectory() as tmpdir:
278+
self.write_update_cache(
279+
tmpdir,
280+
json.dumps(
281+
{"lastChecked": "2026-07-24T00:00:00.000Z", "latestVersion": "0.2.32"}
282+
),
283+
)
284+
path = self.fake_composio(
285+
tmpdir, stdout='{"authenticated": true}', version="0.2.30"
286+
)
287+
context = self.session_context(tmpdir, path)
288+
self.assertIn("local Composio CLI is available and signed in", context)
289+
self.assertIn("A newer Composio CLI (0.2.32) is available", context)
290+
self.assertIn("composio upgrade", context)
291+
292+
def test_session_start_upgrade_nudge_degrades_to_silence(self):
293+
cache = json.dumps(
294+
{"lastChecked": "2026-07-24T00:00:00.000Z", "latestVersion": "0.2.32"}
295+
)
296+
silent_cases = [
297+
("beta-installed", cache, "0.2.32-beta.289"),
298+
("up-to-date", cache, "0.2.32"),
299+
("installed-newer", cache, "0.3.0"),
300+
("prerelease-latest", cache.replace("0.2.32", "0.3.0-beta.1"), "0.2.31"),
301+
("malformed-cache", "not json at all {{{", "0.2.30"),
302+
("cache-missing", None, "0.2.30"),
303+
]
304+
for name, cache_content, installed in silent_cases:
305+
with self.subTest(name):
306+
with tempfile.TemporaryDirectory() as tmpdir:
307+
if cache_content is not None:
308+
self.write_update_cache(tmpdir, cache_content)
309+
path = self.fake_composio(
310+
tmpdir, stdout='{"authenticated": true}', version=installed
311+
)
312+
context = self.session_context(tmpdir, path)
313+
self.assertNotIn(self.NUDGE, context)
314+
249315
def test_prompt_hook_is_surface_aware_for_cached_toolkits(self):
250316
with tempfile.TemporaryDirectory() as tmpdir:
251317
cache = pathlib.Path(tmpdir) / "composio-plugin-toolkits.cache"

0 commit comments

Comments
 (0)