aidevops 3.29.11 → 3.29.13

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
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.29.11-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.13-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.11
1
+ 3.29.13
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.29.11
8
+ # Version: 3.29.13
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.29.11",
3
+ "version": "3.29.13",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import * as readline from "node:readline";
2
3
  import { Hono } from "hono";
3
4
  import { APP_ACTION_ROUTE_MANIFEST, APP_ACTION_STATUS_ROUTE_MANIFEST, BANNED_ROUTE_PATTERNS, createEnvelope, FILE_EXPLORER_ROUTE_MANIFEST, type GuiAppActionId, type GuiAppActionJobSummary, STATUS_ROUTE_MANIFEST, VAULT_STATUS_ROUTE_MANIFEST } from "../../gui-shared/src";
4
5
  import { readFileExplorer } from "./file-adapter";
@@ -150,10 +151,16 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
150
151
  env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true" },
151
152
  stdio: ["ignore", "pipe", "pipe"],
152
153
  });
153
- child.stdout.on("data", (chunk) => appendJobOutput(job, chunk.toString()));
154
- child.stderr.on("data", (chunk) => appendJobOutput(job, chunk.toString()));
154
+ if (child.stdout !== null) {
155
+ const stdoutLines = readline.createInterface({ input: child.stdout, terminal: false });
156
+ stdoutLines.on("line", (line) => appendJobLine(job, line));
157
+ }
158
+ if (child.stderr !== null) {
159
+ const stderrLines = readline.createInterface({ input: child.stderr, terminal: false });
160
+ stderrLines.on("line", (line) => appendJobLine(job, line));
161
+ }
155
162
  child.on("error", (error) => {
156
- appendJobOutput(job, error.message);
163
+ appendJobLine(job, error.message);
157
164
  job.status = "failed";
158
165
  job.finished_at = new Date().toISOString();
159
166
  job.exit_code = 127;
@@ -167,8 +174,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
167
174
  return job;
168
175
  }
169
176
 
170
- function appendJobOutput(job: GuiAppActionJobSummary, output: string): void {
171
- job.output.push(...output.split(/\r?\n/).filter((line) => line.length > 0).map(redactOutputLine));
177
+ function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
178
+ if (line.length === 0) {
179
+ return;
180
+ }
181
+ job.output.push(redactOutputLine(line));
172
182
  if (job.output.length > 400) {
173
183
  job.output.splice(1, job.output.length - 400);
174
184
  }
@@ -17,7 +17,7 @@ const toastSeverityRank: Record<GuiNotificationSeverity, number> = {
17
17
 
18
18
  const warningLineMatchers: Array<(line: string) => boolean> = [
19
19
  (line) => line.startsWith("Pulse stalled"),
20
- (line) => /contribution\(s\) need/i.test(line),
20
+ (line) => /contribution(\(s\)|s)?\s+needs?/i.test(line),
21
21
  (line) => line.startsWith("[OPENCODE MAINTENANCE]"),
22
22
  (line) => line.startsWith("[WARNING]"),
23
23
  (line) => line.startsWith("[WARN]"),
@@ -28,7 +28,7 @@ const toastTitleRules: Array<{ matches: (line: string) => boolean; title: string
28
28
  { matches: (line) => line.startsWith("[ERROR]"), title: "Startup error" },
29
29
  { matches: (line) => line.startsWith("[OPENCODE MAINTENANCE]"), title: "OpenCode maintenance" },
30
30
  { matches: (line) => line.startsWith("Pulse stalled"), title: "Pulse stalled" },
31
- { matches: (line) => /contribution\(s\) need/i.test(line), title: "External contribution review" },
31
+ { matches: (line) => /contribution(\(s\)|s)?\s+needs?/i.test(line), title: "External contribution review" },
32
32
  { matches: (line) => line.startsWith("[WARNING]") || line.startsWith("[WARN]"), title: "Startup warning" },
33
33
  { matches: (line) => line.startsWith("Security: all protections active"), title: "Security protections active" },
34
34
  ];
@@ -93,10 +93,10 @@ function buildToastNotifications(greetingOutput: string): GuiNotificationSummary
93
93
  }
94
94
 
95
95
  function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificationSummary[] {
96
- const setupNeedsUpdate = input.setupTargets.filter((target) => target.needs_update);
97
- const appNeedsUpdate = input.aiApps.filter((app) => app.needs_update);
98
- const authErrors = input.oauthPool.providers.filter((provider) => provider.auth_errors > 0);
99
- const rateLimited = input.oauthPool.providers.filter((provider) => provider.rate_limited > 0);
96
+ const setupNeedsUpdate = (input.setupTargets ?? []).filter((target) => target.needs_update);
97
+ const appNeedsUpdate = (input.aiApps ?? []).filter((app) => app.needs_update);
98
+ const authErrors = (input.oauthPool?.providers ?? []).filter((provider) => provider.auth_errors > 0);
99
+ const rateLimited = (input.oauthPool?.providers ?? []).filter((provider) => provider.rate_limited > 0);
100
100
  const notifications: GuiNotificationSummary[] = [];
101
101
 
102
102
  if (input.restartRequired) {
@@ -222,6 +222,11 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, selecte
222
222
  ? []
223
223
  : status.opencode_sessions.sessions.filter((session) => session.repo_path_ref === selectedRepo.path_ref);
224
224
  const activeSessionId = sessions.some((session) => session.id_ref === selectedSessionId) ? selectedSessionId : sessions[0]?.id_ref;
225
+ useEffect(() => {
226
+ if (activeSessionId !== selectedSessionId) {
227
+ setSelectedSessionId(activeSessionId);
228
+ }
229
+ }, [activeSessionId, selectedSessionId, setSelectedSessionId]);
225
230
  const canSelectPreviousRepo = selectedLocalRepoIndex > 0;
226
231
  const canSelectNextRepo = selectedLocalRepoIndex < repos.length - 1;
227
232
 
@@ -189,21 +189,30 @@ function NotificationsMenu({ notifications, openSurface }: { notifications: GuiN
189
189
  <span>{active.length} active</span>
190
190
  </div>
191
191
  {preview.length === 0 ? <p>No current aidevops notifications.</p> : null}
192
- {preview.map((notification) => <NotificationPreview key={notification.id} notification={notification} />)}
192
+ {preview.map((notification) => (
193
+ <NotificationPreview
194
+ key={notification.id}
195
+ notification={notification}
196
+ onClick={() => {
197
+ const action = notification.actions.find((candidate) => candidate.enabled && candidate.kind === "surface" && isSurfaceId(candidate.surface_id));
198
+ openSurface(action?.kind === "surface" && isSurfaceId(action.surface_id) ? action.surface_id : "notifications");
199
+ }}
200
+ />
201
+ ))}
193
202
  <button onClick={() => openSurface("notifications")} role="menuitem" type="button">Open notifications</button>
194
203
  </div>
195
204
  );
196
205
  }
197
206
 
198
- function NotificationPreview({ notification }: { notification: GuiNotificationSummary }): ReactElement {
207
+ function NotificationPreview({ notification, onClick }: { notification: GuiNotificationSummary; onClick: () => void }): ReactElement {
199
208
  return (
200
- <article className={`notification-preview ${notification.severity}`}>
209
+ <button className={`notification-preview ${notification.severity}`} onClick={onClick} role="menuitem" type="button">
201
210
  <span aria-hidden="true"><NotificationIcon notification={notification} /></span>
202
211
  <div>
203
212
  <strong>{notification.title}</strong>
204
213
  <small>{notification.category} · {notification.status}</small>
205
214
  </div>
206
- </article>
215
+ </button>
207
216
  );
208
217
  }
209
218
 
@@ -528,7 +537,7 @@ function NotificationCard({ notification, openSurface }: { notification: GuiNoti
528
537
  return <button className="secondary-action" disabled={!action.enabled} key={action.id} onClick={() => openSurface(surfaceId)} type="button">{action.label}</button>;
529
538
  }
530
539
 
531
- return <button className="secondary-action" disabled key={action.id} title={action.command_preview} type="button">{action.label}</button>;
540
+ return <button className="secondary-action" disabled={!action.enabled} key={action.id} title={action.command_preview} type="button">{action.label}</button>;
532
541
  })}
533
542
  </div>
534
543
  </div>
@@ -3,12 +3,29 @@ import { sortConversationMessageParts, sortConversationMessages, type GuiConvers
3
3
  import { text } from "./app-model";
4
4
  import { conversationThreads } from "./conversation-fixtures";
5
5
 
6
- export function CommsConversationSurface({ mode }: { mode: "channels" | "directMessages" | "people" }) {
7
- const conversations = mode === "directMessages" ? conversationThreads.filter((thread) => thread.conversation.type === "dm" || thread.conversation.type === "group_dm") : conversationThreads.filter((thread) => thread.conversation.type === "channel");
8
- const selectedThread = conversations[0] ?? conversationThreads[0];
6
+ export function CommsConversationSurface({ mode, threads = conversationThreads }: { mode: "channels" | "directMessages" | "people"; threads?: GuiConversationThread[] }) {
7
+ const availableThreads = threads ?? [];
8
+ const conversations = mode === "directMessages" ? availableThreads.filter((thread) => thread.conversation.type === "dm" || thread.conversation.type === "group_dm") : availableThreads.filter((thread) => thread.conversation.type === "channel");
9
+ const selectedThread = conversations[0] ?? availableThreads[0];
9
10
  const partsByMessage = new Map<string, GuiConversationMessagePart[]>();
10
11
 
11
- for (const part of sortConversationMessageParts(selectedThread.parts)) {
12
+ if (!selectedThread) {
13
+ return (
14
+ <section className="chat-surface comms-surface" aria-label={mode === "directMessages" ? text.directMessages : text.channels} data-tour="comms-conversations">
15
+ <aside className="chat-thread-panel conversation-list-panel" aria-label="Conversation list">
16
+ <header className="chat-thread-header">
17
+ <div>
18
+ <p className="eyebrow">Unified conversation model</p>
19
+ <h2>{mode === "directMessages" ? <FiAtSign aria-hidden="true" /> : <FiHash aria-hidden="true" />} {mode === "directMessages" ? text.directMessages : text.channels}</h2>
20
+ </div>
21
+ </header>
22
+ <div className="notice compact-notice">No conversations yet</div>
23
+ </aside>
24
+ </section>
25
+ );
26
+ }
27
+
28
+ for (const part of sortConversationMessageParts(conversationParts(selectedThread))) {
12
29
  partsByMessage.set(part.message_id, [...(partsByMessage.get(part.message_id) ?? []), part]);
13
30
  }
14
31
 
@@ -37,10 +54,10 @@ export function CommsConversationSurface({ mode }: { mode: "channels" | "directM
37
54
  <span className="count-pill">{participantSummary(selectedThread)}</span>
38
55
  </header>
39
56
  <div className="participant-strip">
40
- {selectedThread.participants.map((participant) => <span key={participant.id}>{participant.display_name}<small>{participant.kind}</small></span>)}
57
+ {conversationParticipants(selectedThread).map((participant) => <span key={participant.id}>{participant.display_name}<small>{participant.kind}</small></span>)}
41
58
  </div>
42
59
  <div className="chat-message-list" data-tour="comms-message-timeline">
43
- {sortConversationMessages(selectedThread.messages).map((message) => <ConversationMessageRow key={message.id} message={message} parts={partsByMessage.get(message.id) ?? []} thread={selectedThread} />)}
60
+ {sortConversationMessages(conversationMessages(selectedThread)).map((message) => <ConversationMessageRow key={message.id} message={message} parts={partsByMessage.get(message.id) ?? []} thread={selectedThread} />)}
44
61
  </div>
45
62
  <form className="chat-composer" aria-label="Conversation composer">
46
63
  <textarea disabled placeholder={text.chatInputPlaceholder} />
@@ -52,7 +69,7 @@ export function CommsConversationSurface({ mode }: { mode: "channels" | "directM
52
69
  }
53
70
 
54
71
  function ConversationListItem({ thread }: { thread: GuiConversationThread }) {
55
- const latestMessage = sortConversationMessages(thread.messages).at(-1);
72
+ const latestMessage = sortConversationMessages(conversationMessages(thread)).at(-1);
56
73
  const unread = unreadCount(thread);
57
74
 
58
75
  return (
@@ -67,8 +84,8 @@ function ConversationListItem({ thread }: { thread: GuiConversationThread }) {
67
84
  }
68
85
 
69
86
  function ConversationMessageRow({ message, parts, thread }: { message: GuiConversationMessage; parts: GuiConversationMessagePart[]; thread: GuiConversationThread }) {
70
- const sender = thread.participants.find((participant) => participant.id === message.sender_participant_id);
71
- const reactions = thread.reactions.filter((reaction) => reaction.message_id === message.id);
87
+ const sender = conversationParticipants(thread).find((participant) => participant.id === message.sender_participant_id);
88
+ const reactions = conversationReactions(thread).filter((reaction) => reaction.message_id === message.id);
72
89
  const speaker = message.sender_kind === "human" ? "user" : "assistant";
73
90
 
74
91
  return (
@@ -82,12 +99,32 @@ function ConversationMessageRow({ message, parts, thread }: { message: GuiConver
82
99
  }
83
100
 
84
101
  function participantSummary(thread: GuiConversationThread): string {
85
- const active = thread.participants.filter((participant) => participant.membership_state === "active").length;
102
+ const active = conversationParticipants(thread).filter((participant) => participant.membership_state === "active").length;
86
103
  return `${active} members`;
87
104
  }
88
105
 
89
106
  function unreadCount(thread: GuiConversationThread): number {
90
- const lastSequence = Math.max(0, ...thread.messages.map((message) => message.sequence));
91
- const localRead = thread.read_states[0]?.last_read_sequence ?? 0;
107
+ const lastSequence = Math.max(0, ...conversationMessages(thread).map((message) => message.sequence));
108
+ const localRead = conversationReadStates(thread)[0]?.last_read_sequence ?? 0;
92
109
  return Math.max(0, lastSequence - localRead);
93
110
  }
111
+
112
+ function conversationMessages(thread: GuiConversationThread): GuiConversationMessage[] {
113
+ return thread.messages ?? [];
114
+ }
115
+
116
+ function conversationParts(thread: GuiConversationThread): GuiConversationMessagePart[] {
117
+ return thread.parts ?? [];
118
+ }
119
+
120
+ function conversationParticipants(thread: GuiConversationThread): GuiConversationThread["participants"] {
121
+ return thread.participants ?? [];
122
+ }
123
+
124
+ function conversationReactions(thread: GuiConversationThread): GuiConversationThread["reactions"] {
125
+ return thread.reactions ?? [];
126
+ }
127
+
128
+ function conversationReadStates(thread: GuiConversationThread): GuiConversationThread["read_states"] {
129
+ return thread.read_states ?? [];
130
+ }
@@ -19,6 +19,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
19
19
  const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
20
20
  const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
21
21
  const [recommendedPlatform, setRecommendedPlatform] = useState<RecommendedPlatformFilterId>("all");
22
+ const [recommendedOs, setRecommendedOs] = useState<RecommendedOsId>("all");
22
23
  const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
23
24
  const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
24
25
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
@@ -27,7 +28,9 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
27
28
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
28
29
  const managedApps = sortedManagedApps(status.managed_apps).map((app) => applyManagedPolicyToggles(app, policyToggles[app.id]));
29
30
  const visibleManagedApps = managedApps.filter((app) => managedCategoryForApp(app) === managedCategory);
30
- const visibleRecommendedApps = recommendedApps.filter((app) => recommendedPlatform === "all" || app.platforms.includes(recommendedPlatform));
31
+ const visibleRecommendedApps = recommendedApps.filter((app) => recommendedAppMatchesFilters(app, recommendedPlatform, recommendedOs));
32
+ const selectRecommendedPlatform = (value: RecommendedPlatformFilterId) => setRecommendedPlatform((current) => nextRecommendedFilterValue(current, value, "all"));
33
+ const selectRecommendedOs = (value: RecommendedOsId) => setRecommendedOs((current) => nextRecommendedFilterValue(current, value, "all"));
31
34
 
32
35
  useEffect(() => {
33
36
  setExpandedAppId(visibleManagedApps[0]?.id ?? null);
@@ -84,7 +87,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
84
87
  ))}
85
88
  </div>
86
89
  <p className="empty-state compact-notice">Install/update actions run as allowlisted background jobs and stream inside each app panel. xterm.js with a node-pty bridge is the right next step for full TUI apps such as OpenCode; this view starts with non-interactive command logs.</p>
87
- </> : <RecommendedAppsSurface apps={visibleRecommendedApps} platform={recommendedPlatform} setPlatform={setRecommendedPlatform} />}
90
+ </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} platform={recommendedPlatform} setOs={selectRecommendedOs} setPlatform={selectRecommendedPlatform} />}
88
91
  </section>
89
92
  );
90
93
  }
@@ -119,11 +122,9 @@ function ManagedAppPanel({ app, expanded, job, onJob, onPolicyToggle, onToggle,
119
122
  <div className="managed-app-details">
120
123
  <ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
121
124
  <ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
122
- <AppMeta label="Installed" value={app.installed_version} />
123
- <AppMeta label="Latest" value={app.latest_version} />
124
- <AppMeta label={text.path} value={app.install_path_ref} />
125
125
  {job ? <AppLogLink job={job} /> : null}
126
126
  </div>
127
+ <AppMeta className="managed-app-path" label={text.path} value={app.install_path_ref} />
127
128
  {job ? <AppActionTerminal job={job} /> : null}
128
129
  {policyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} />)}
129
130
  {job === null && policyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
@@ -145,11 +146,11 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
145
146
  }
146
147
 
147
148
  function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
148
- return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span>{label}</button>;
149
+ return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
149
150
  }
150
151
 
151
- function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
152
- return <span className="app-meta"><small>{label}</small><strong>{value}</strong></span>;
152
+ function AppMeta({ className = "", label, value }: { className?: string; label: string; value: string }): ReactElement {
153
+ return <span className={className.length > 0 ? `app-meta ${className}` : "app-meta"}><small>{label}</small><strong>{value}</strong></span>;
153
154
  }
154
155
 
155
156
  function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
@@ -224,7 +225,14 @@ function jobForApp(appId: string, jobs: Record<string, GuiAppActionJobSummary>,
224
225
  return selectedJob;
225
226
  }
226
227
 
227
- return Object.values(jobs).reverse().find((job) => job.app_id === appId) ?? null;
228
+ let latestJob: GuiAppActionJobSummary | null = null;
229
+ for (const job of Object.values(jobs)) {
230
+ if (job.app_id === appId && (latestJob === null || job.started_at > latestJob.started_at)) {
231
+ latestJob = job;
232
+ }
233
+ }
234
+
235
+ return latestJob;
228
236
  }
229
237
 
230
238
  function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[] {
@@ -283,7 +291,7 @@ interface RecommendedApp {
283
291
  }
284
292
 
285
293
  const appCollectionTabs: TabOption<AppCollectionId>[] = [
286
- { id: "aidevops", label: "aidevops" },
294
+ { id: "aidevops", label: "AIDevOps" },
287
295
  { id: "recommended", label: "Recommended" },
288
296
  ];
289
297
 
@@ -303,6 +311,15 @@ const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
303
311
  { id: "api", label: "API" },
304
312
  ];
305
313
 
314
+ const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
315
+ { id: "all", label: "All OS" },
316
+ { id: "macos", label: "macOS" },
317
+ { id: "linux", label: "Linux" },
318
+ { id: "windows", label: "Windows" },
319
+ { id: "ios", label: "iOS" },
320
+ { id: "android", label: "Android" },
321
+ ];
322
+
306
323
  const recommendedApps = ([
307
324
  { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows", "ios"], platforms: [] },
308
325
  { name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
@@ -359,16 +376,19 @@ function TabNav<T extends string>({ label, onChange, tabs, value }: { label: str
359
376
  return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
360
377
  }
361
378
 
362
- function RecommendedAppsSurface({ apps, platform, setPlatform }: { apps: RecommendedApp[]; platform: RecommendedPlatformFilterId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
379
+ function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform }: { apps: RecommendedApp[]; os: RecommendedOsId; platform: RecommendedPlatformFilterId; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
363
380
  return <>
364
- <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
381
+ <div className="recommended-filter-tabs">
382
+ <TabNav label="Recommended app operating system filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
383
+ <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
384
+ </div>
365
385
  <div className="recommended-app-grid">
366
- {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setPlatform={setPlatform} />)}
386
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setOs={setOs} setPlatform={setPlatform} />)}
367
387
  </div>
368
388
  </>;
369
389
  }
370
390
 
371
- function RecommendedAppCard({ app, setPlatform }: { app: RecommendedApp; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
391
+ function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
372
392
  return <article className="recommended-app-card">
373
393
  <div>
374
394
  <strong>{app.name}</strong>
@@ -376,7 +396,7 @@ function RecommendedAppCard({ app, setPlatform }: { app: RecommendedApp; setPlat
376
396
  </div>
377
397
  <div className="app-icon-groups">
378
398
  <PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
379
- <OsIconList os={app.os} />
399
+ <OsIconList os={app.os} setOs={setOs} />
380
400
  </div>
381
401
  <div className="managed-app-links plain-links">
382
402
  <OriginLink href={app.websiteUrl} label={text.website} />
@@ -410,15 +430,15 @@ function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatf
410
430
  return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
411
431
  }
412
432
 
413
- function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement | null {
433
+ function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
414
434
  if (os.length === 0) {
415
435
  return null;
416
436
  }
417
437
 
418
- return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} />)}</span>;
438
+ return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} setOs={setOs} />)}</span>;
419
439
  }
420
440
 
421
- function OsIcon({ id }: { id: RecommendedOsId }): ReactElement {
441
+ function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
422
442
  const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
423
443
  all: { Icon: FiGlobe, label: "Web app" },
424
444
  macos: { Icon: FaApple, label: "macOS" },
@@ -429,7 +449,18 @@ function OsIcon({ id }: { id: RecommendedOsId }): ReactElement {
429
449
  };
430
450
  const { Icon, label } = iconMap[id];
431
451
 
432
- return <span aria-label={label} data-tooltip={label} role="img" title={label}><Icon aria-hidden="true" focusable="false" /></span>;
452
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
453
+ }
454
+
455
+ function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
456
+ const platformMatches = platform === "all" || app.platforms.includes(platform);
457
+ const osMatches = os === "all" || app.os.includes(os);
458
+
459
+ return platformMatches && osMatches;
460
+ }
461
+
462
+ export function nextRecommendedFilterValue<T extends string>(current: T, next: T, defaultValue: T): T {
463
+ return current === next && next !== defaultValue ? defaultValue : next;
433
464
  }
434
465
 
435
466
  function applyManagedPolicyToggles(app: GuiManagedAppSummary, policy: Partial<Record<ManagedPolicyId, boolean>> | undefined): GuiManagedAppSummary {
@@ -147,7 +147,7 @@ export const text = {
147
147
  aidevops: "aidevops",
148
148
  appShell: "App shell",
149
149
  apps: "Apps",
150
- appsIntro: "App and CLI inventory for tools installed or updated by aidevops, with origin links, install/update policy, versions, paths, and allowlisted background actions.",
150
+ appsIntro: "The app toolkit we use and recommend to enable all the things we can do with AI, development, and operations to create and share our work. Opinionated preferences based on open-source-first, lowest-costs, highest-quality UI & UX, teamwork, data security, privacy, and AI-usage capabilities.",
151
151
  appStatusPending: "adapter pending",
152
152
  brands: "Brands",
153
153
  brandsIntro: "Draft brand inventory for names and website references.",
@@ -13,6 +13,14 @@
13
13
  --font-family-app: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
14
14
  --font-size-app: 16px;
15
15
  --glass-panel-shadow: inset 0 1px 0 rgb(255 255 255 / 58%), inset 0 -1px 0 rgb(0 0 0 / 3%), var(--shadow-soft);
16
+ --notification-error-bg: rgb(207 34 46 / 10%);
17
+ --notification-error-fg: #cf222e;
18
+ --notification-info-bg: rgb(9 105 218 / 10%);
19
+ --notification-info-fg: #0969da;
20
+ --notification-success-bg: rgb(26 127 55 / 10%);
21
+ --notification-success-fg: #1a7f37;
22
+ --notification-warning-bg: rgb(191 135 0 / 12%);
23
+ --notification-warning-fg: #9a6700;
16
24
  --shell-gutter: 8px;
17
25
  --shadow-soft: 0 18px 56px rgb(15 23 42 / 9%);
18
26
  --surface-elevated: #ffffff;
@@ -43,6 +51,14 @@
43
51
  --border-hover: color-mix(in srgb, rgb(255 255 255 / 22%) 74%, rgb(255 255 255 / 40%));
44
52
  --code-bg: hsl(var(--accent-hue) 18% 6%);
45
53
  --glass-panel-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), inset 0 -1px 0 rgb(0 0 0 / 24%), var(--shadow-soft);
54
+ --notification-error-bg: rgb(255 123 114 / 13%);
55
+ --notification-error-fg: #ff7b72;
56
+ --notification-info-bg: rgb(88 166 255 / 13%);
57
+ --notification-info-fg: #58a6ff;
58
+ --notification-success-bg: rgb(86 211 100 / 13%);
59
+ --notification-success-fg: #56d364;
60
+ --notification-warning-bg: rgb(210 153 34 / 15%);
61
+ --notification-warning-fg: #d29922;
46
62
  --shadow-soft: 0 24px 80px rgb(0 0 0 / 40%);
47
63
  --surface-elevated: #151515;
48
64
  --surface-muted: #202020;
@@ -927,9 +943,8 @@ code {
927
943
  .header-actions > .header-action-menu {
928
944
  align-items: center;
929
945
  display: inline-flex;
930
- flex: 0 0 38px;
946
+ flex: 0 0 auto;
931
947
  justify-content: center;
932
- width: 38px;
933
948
  }
934
949
 
935
950
  .workspace-search {
@@ -985,6 +1000,7 @@ code {
985
1000
  }
986
1001
 
987
1002
  .header-action-menu {
1003
+ gap: 10px;
988
1004
  position: relative;
989
1005
  }
990
1006
 
@@ -1082,11 +1098,22 @@ code {
1082
1098
  }
1083
1099
 
1084
1100
  .notification-preview {
1101
+ background: var(--notification-bg, transparent);
1085
1102
  border: 1px solid var(--border);
1086
1103
  border-left: 4px solid var(--notification-color, var(--accent));
1087
1104
  border-radius: 12px;
1105
+ color: var(--text-secondary);
1106
+ cursor: pointer;
1088
1107
  gap: 10px;
1089
1108
  padding: 10px;
1109
+ text-align: left;
1110
+ width: 100%;
1111
+ }
1112
+
1113
+ .notification-preview:hover,
1114
+ .notification-preview:focus-visible {
1115
+ border-color: var(--notification-color, var(--accent));
1116
+ outline: none;
1090
1117
  }
1091
1118
 
1092
1119
  .notification-preview > span {
@@ -1106,29 +1133,29 @@ code {
1106
1133
 
1107
1134
  .notification-preview.error,
1108
1135
  .github-notification-card.error {
1109
- --notification-color: #cf222e;
1110
- --notification-bg: rgb(207 34 46 / 10%);
1136
+ --notification-color: var(--notification-error-fg);
1137
+ --notification-bg: var(--notification-error-bg);
1111
1138
  }
1112
1139
 
1113
1140
  .notification-preview.warning,
1114
1141
  .github-notification-card.warning {
1115
- --notification-color: #9a6700;
1116
- --notification-bg: rgb(191 135 0 / 12%);
1142
+ --notification-color: var(--notification-warning-fg);
1143
+ --notification-bg: var(--notification-warning-bg);
1117
1144
  }
1118
1145
 
1119
1146
  .notification-preview.info,
1120
1147
  .github-notification-card.info {
1121
- --notification-color: #0969da;
1122
- --notification-bg: rgb(9 105 218 / 10%);
1148
+ --notification-color: var(--notification-info-fg);
1149
+ --notification-bg: var(--notification-info-bg);
1123
1150
  }
1124
1151
 
1125
1152
  .notification-preview.success,
1126
1153
  .github-notification-card.success {
1127
- --notification-color: #1a7f37;
1128
- --notification-bg: rgb(26 127 55 / 10%);
1154
+ --notification-color: var(--notification-success-fg);
1155
+ --notification-bg: var(--notification-success-bg);
1129
1156
  }
1130
1157
 
1131
- .popover-menu button,
1158
+ .popover-menu button:not(.notification-preview),
1132
1159
  .popover-menu a {
1133
1160
  align-items: center;
1134
1161
  background: transparent;
@@ -1142,9 +1169,9 @@ code {
1142
1169
  text-decoration: none;
1143
1170
  }
1144
1171
 
1145
- .popover-menu button:hover:not(:disabled),
1172
+ .popover-menu button:not(.notification-preview):hover:not(:disabled),
1146
1173
  .popover-menu a:hover,
1147
- .popover-menu button:focus-visible,
1174
+ .popover-menu button:not(.notification-preview):focus-visible,
1148
1175
  .popover-menu a:focus-visible {
1149
1176
  background: var(--surface-muted);
1150
1177
  color: var(--accent);
@@ -1757,15 +1784,23 @@ code {
1757
1784
  }
1758
1785
 
1759
1786
  .section-heading {
1760
- margin-bottom: 18px;
1787
+ margin-bottom: 16px;
1761
1788
  max-width: 860px;
1762
1789
  }
1763
1790
 
1791
+ .section-heading .eyebrow {
1792
+ margin: 0 0 9px;
1793
+ }
1794
+
1764
1795
  .section-heading h2 {
1765
1796
  font-size: 1.7rem;
1766
1797
  letter-spacing: -0.05em;
1767
1798
  line-height: 1.04;
1768
- margin: 6px 0 8px;
1799
+ margin: 0 0 16px;
1800
+ }
1801
+
1802
+ .section-heading p {
1803
+ margin: 0;
1769
1804
  }
1770
1805
 
1771
1806
  .section-heading p,
@@ -1976,13 +2011,25 @@ code {
1976
2011
  }
1977
2012
 
1978
2013
  .apps-surface {
1979
- display: grid;
1980
2014
  align-content: start;
1981
- gap: 10px;
2015
+ display: grid;
2016
+ gap: 0;
1982
2017
  }
1983
2018
 
1984
2019
  .apps-surface .section-heading {
1985
- margin-bottom: 8px;
2020
+ margin-bottom: 18px;
2021
+ }
2022
+
2023
+ .apps-surface > .app-subnav {
2024
+ margin-bottom: 18px;
2025
+ }
2026
+
2027
+ .apps-surface > .app-subnav + .app-subnav {
2028
+ margin-bottom: 16px;
2029
+ }
2030
+
2031
+ .apps-surface .compact-notice {
2032
+ margin-bottom: 0;
1986
2033
  }
1987
2034
 
1988
2035
  .data-table,
@@ -1994,8 +2041,8 @@ code {
1994
2041
  }
1995
2042
 
1996
2043
  .managed-app-list {
1997
- gap: 12px;
1998
- margin-bottom: 12px;
2044
+ gap: 16px;
2045
+ margin-bottom: 16px;
1999
2046
  overflow: visible;
2000
2047
  }
2001
2048
 
@@ -2006,7 +2053,7 @@ code {
2006
2053
  border: 1px solid var(--border);
2007
2054
  border-radius: 18px;
2008
2055
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
2009
- overflow: hidden;
2056
+ overflow: visible;
2010
2057
  transition: background 140ms ease, border-color 140ms ease;
2011
2058
  }
2012
2059
 
@@ -2044,14 +2091,23 @@ code {
2044
2091
  .managed-app-title-block,
2045
2092
  .app-meta {
2046
2093
  display: grid;
2047
- gap: 7px;
2048
2094
  min-width: 0;
2049
2095
  }
2050
2096
 
2097
+ .managed-app-title-block {
2098
+ gap: 0;
2099
+ }
2100
+
2101
+ .managed-app-title-block .eyebrow {
2102
+ line-height: 1;
2103
+ margin: 0 0 8px;
2104
+ }
2105
+
2051
2106
  .managed-app-title-block strong {
2052
2107
  font-size: clamp(1.12rem, 2vw, 1.4rem);
2053
2108
  letter-spacing: -0.04em;
2054
2109
  line-height: 1.15;
2110
+ margin-bottom: 16px;
2055
2111
  }
2056
2112
 
2057
2113
  .managed-app-title-block > span:last-child {
@@ -2070,14 +2126,16 @@ code {
2070
2126
 
2071
2127
  .managed-summary-chip {
2072
2128
  align-items: center;
2129
+ align-content: center;
2073
2130
  background: color-mix(in srgb, var(--surface-elevated) 92%, transparent);
2074
2131
  border: 1px solid var(--border);
2075
2132
  border-radius: 999px;
2076
2133
  display: inline-grid;
2077
2134
  gap: 1px;
2078
2135
  justify-items: center;
2136
+ min-height: 58px;
2079
2137
  min-width: 118px;
2080
- padding: 7px 11px;
2138
+ padding: 7px 12px;
2081
2139
  text-align: center;
2082
2140
  }
2083
2141
 
@@ -2093,6 +2151,7 @@ code {
2093
2151
  color: var(--text-primary);
2094
2152
  font-size: 0.84rem;
2095
2153
  font-weight: 800;
2154
+ line-height: 1.15;
2096
2155
  max-width: 18ch;
2097
2156
  overflow: hidden;
2098
2157
  text-overflow: ellipsis;
@@ -2112,7 +2171,7 @@ code {
2112
2171
  .managed-app-body {
2113
2172
  border-top: 1px solid var(--border);
2114
2173
  display: grid;
2115
- gap: 14px;
2174
+ gap: 16px;
2116
2175
  padding: 0 16px 16px;
2117
2176
  position: relative;
2118
2177
  }
@@ -2120,9 +2179,9 @@ code {
2120
2179
  .managed-app-toolbar {
2121
2180
  align-items: start;
2122
2181
  display: grid;
2123
- gap: 12px;
2182
+ gap: 16px;
2124
2183
  grid-template-columns: minmax(0, 1fr) auto;
2125
- padding-top: 14px;
2184
+ padding-top: 16px;
2126
2185
  }
2127
2186
 
2128
2187
  .managed-app-links,
@@ -2168,11 +2227,16 @@ code {
2168
2227
  grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
2169
2228
  }
2170
2229
 
2230
+ .managed-app-path {
2231
+ min-height: auto;
2232
+ }
2233
+
2171
2234
  .managed-toggle,
2172
2235
  .app-meta {
2173
2236
  background: var(--surface-elevated);
2174
2237
  border: 1px solid var(--border);
2175
2238
  border-radius: 12px;
2239
+ gap: 7px;
2176
2240
  min-width: 0;
2177
2241
  min-height: 64px;
2178
2242
  padding: 10px 12px;
@@ -2184,10 +2248,15 @@ code {
2184
2248
  display: flex;
2185
2249
  font-size: 0.78rem;
2186
2250
  font-weight: 800;
2187
- gap: 8px;
2251
+ gap: 12px;
2252
+ justify-content: space-between;
2188
2253
  text-transform: uppercase;
2189
2254
  }
2190
2255
 
2256
+ .managed-toggle-label {
2257
+ min-width: 0;
2258
+ }
2259
+
2191
2260
  button.managed-toggle {
2192
2261
  cursor: pointer;
2193
2262
  text-align: left;
@@ -2209,6 +2278,7 @@ button.managed-toggle:focus-visible {
2209
2278
  border: 1px solid color-mix(in srgb, #ef4444 44%, var(--border));
2210
2279
  border-radius: 999px;
2211
2280
  display: inline-flex;
2281
+ flex: 0 0 auto;
2212
2282
  padding: 2px;
2213
2283
  width: 34px;
2214
2284
  }
@@ -2266,19 +2336,27 @@ button.managed-toggle:focus-visible {
2266
2336
  color: var(--accent);
2267
2337
  display: inline-flex;
2268
2338
  gap: 7px;
2269
- min-height: 42px;
2270
2339
  justify-content: center;
2340
+ line-height: 1;
2341
+ min-height: 42px;
2271
2342
  padding: 0 12px;
2272
2343
  position: relative;
2273
2344
  transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
2274
2345
  }
2275
2346
 
2276
2347
  .app-action-button span {
2348
+ align-items: center;
2349
+ display: inline-flex;
2277
2350
  font-size: 0.7rem;
2278
2351
  font-weight: 900;
2352
+ line-height: 1;
2279
2353
  text-transform: uppercase;
2280
2354
  }
2281
2355
 
2356
+ .app-action-button svg {
2357
+ flex: 0 0 auto;
2358
+ }
2359
+
2282
2360
  .app-action-button:hover,
2283
2361
  .app-action-button:focus-visible {
2284
2362
  background: var(--accent);
@@ -2394,7 +2472,7 @@ button.managed-toggle:focus-visible {
2394
2472
  content: attr(data-tooltip);
2395
2473
  font-size: 0.72rem;
2396
2474
  font-weight: 700;
2397
- left: clamp(10px, 50%, calc(100vw - 330px));
2475
+ left: 50%;
2398
2476
  line-height: 1.35;
2399
2477
  max-width: min(320px, 72vw);
2400
2478
  opacity: 0;
@@ -2405,7 +2483,7 @@ button.managed-toggle:focus-visible {
2405
2483
  transform: translate(-50%, 4px);
2406
2484
  transition: opacity 140ms ease, transform 140ms ease;
2407
2485
  width: max-content;
2408
- z-index: 400;
2486
+ z-index: 1000;
2409
2487
  }
2410
2488
 
2411
2489
  [data-tooltip]:hover::after,
@@ -2496,6 +2574,13 @@ button.managed-toggle:focus-visible {
2496
2574
  justify-self: start;
2497
2575
  }
2498
2576
 
2577
+ .recommended-filter-tabs {
2578
+ display: flex;
2579
+ flex-wrap: wrap;
2580
+ gap: 16px;
2581
+ margin-bottom: 16px;
2582
+ }
2583
+
2499
2584
  .recommended-app-grid {
2500
2585
  display: grid;
2501
2586
  gap: 12px;
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.29.11
15
+ # Version: 3.29.13
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)