Compare commits

..

14 Commits

Author SHA1 Message Date
Bohan Jiang
beeb8bc107 Merge pull request #361 from JimmyPang02/fix/issue-mention-on-comment
fix: issue mentions should not suppress on_comment trigger
2026-04-03 18:42:16 +08:00
Naiyuan Qing
5548d60dbb Revert "fix(issues): prevent sticky mini bar oscillation with height placeholder"
This reverts commit 9fb25f4543.
2026-04-03 18:35:52 +08:00
Naiyuan Qing
9fb25f4543 fix(issues): prevent sticky mini bar oscillation with height placeholder
When the agent live card collapses to sticky mode, its height drops from
~320px to ~40px. This layout shift caused content below to jump up,
re-triggering IntersectionObserver and creating an infinite loop.

Fix: capture the card's expanded height before collapsing, then set
minHeight on a wrapper div to preserve the space. Content below stays
put, sentinel stays out of view, no oscillation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:30:09 +08:00
Bohan Jiang
33140d4c5a docs(web): add v0.1.6 changelog entry (#391)
* docs(web): add v0.1.5 changelog entry for 2026-04-02

* docs(web): add v0.1.6 changelog entry for 2026-04-03
2026-04-03 16:39:56 +08:00
Jiayuan Zhang
9b8cc0870b Merge pull request #388 from multica-ai/agent/lambda/1286014b
feat(cli): add agent, skill, and runtime management commands
2026-04-03 16:14:37 +08:00
Naiyuan Qing
ce40b66c60 Merge pull request #390 from multica-ai/feat/agent-live-card-sticky-minibar
feat(issues): sticky mini bar for agent live card + toast icon colors
2026-04-03 16:09:27 +08:00
Naiyuan Qing
56b49cb2a6 feat(issues): use ActorAvatar in agent live card header
Replace hand-rolled Bot icon circle with ActorAvatar component so
agent custom avatars display correctly, consistent with comment cards
and other agent-rendered UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:12 +08:00
Naiyuan Qing
4353340ea6 feat(issues): sticky mini bar for agent live card + toast icon colors
Agent live card now uses the sentinel pattern to detect when it scrolls
out of view. When stuck, it collapses to a compact header bar with brand
styling and backdrop blur, with a ChevronUp button to scroll back.
When scrolled back into view, the card seamlessly expands to full view.

Also adds semantic colors to Sonner toast icons (success/info/warning/
error/loading) and fixes icon-to-text alignment in toasts globally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:05:35 +08:00
Jiayuan
91cbf32fd1 fix(cli): address code review feedback for agent/skill/runtime commands
- Add --config flag to skill create/update (accepts JSON string)
- Add --runtime-config flag to agent create/update (accepts JSON string)
- Add --yes flag to skill delete with confirmation prompt
- Improve agent skills set error message (guide users to --skill-ids '')
- Validate --days range (1-365) in runtime usage
- Include last known status in ping/update timeout errors
2026-04-03 16:00:12 +08:00
Jiayuan
10b482fac2 feat(cli): add agent, skill, and runtime management commands
Expand CLI coverage for agent/skill/runtime APIs that previously had no
CLI wrappers despite having fully implemented backend endpoints.

Agent commands (was: only list):
- agent get/create/update/archive/restore/tasks
- agent skills list/set
- agent list --include-archived

Skill commands (new, was: 0% coverage):
- skill list/get/create/update/delete/import
- skill files list/upsert/delete

Runtime commands (new, was: 0% coverage):
- runtime list/usage/activity/ping/update
- ping and update support --wait for polling
2026-04-03 15:41:17 +08:00
Jiayuan Zhang
0024208354 Merge pull request #387 from multica-ai/fix/filter-archived-agents-from-dropdowns
fix(web): filter archived agents from dropdown selectors
2026-04-03 15:41:01 +08:00
Bohan Jiang
32a3a3543d docs(web): add v0.1.5 changelog entry for 2026-04-02 (#386) 2026-04-03 15:40:15 +08:00
Jiayuan
e314badf18 fix(web): filter archived agents from all dropdown selectors
Add `!a.archived_at` check to agent filters in create-issue modal,
issues-header filter panel, issue-detail assignee dropdown, and
issue-detail subscriber list.

assignee-picker and mention-suggestion already filter correctly.
2026-04-03 15:39:27 +08:00
Jimmy Peng
6799458807 fix: issue mentions should not suppress on_comment trigger 2026-04-03 01:12:38 +08:00
17 changed files with 1448 additions and 167 deletions

View File

@@ -133,24 +133,6 @@ function InboxDetailLabel({ item }: { item: InboxItem }) {
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "task_completed": {
const label = details.trigger === "comment" ? "Completed follow-up" : "Task completed";
if (details.pr_url) {
return (
<span className="inline-flex items-center gap-1">
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
PR
</a>
</span>
);
}
return <span>{label}</span>;
}
case "task_failed": {
if (details.error) return <span>Task failed: {details.error}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}

View File

@@ -30,3 +30,12 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
<CircleCheckIcon className="size-4 text-success" />
),
info: (
<InfoIcon className="size-4" />
<InfoIcon className="size-4 text-info" />
),
warning: (
<TriangleAlertIcon className="size-4" />
<TriangleAlertIcon className="size-4 text-warning" />
),
error: (
<OctagonXIcon className="size-4" />
<OctagonXIcon className="size-4 text-destructive" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<Loader2Icon className="size-4 animate-spin text-brand" />
),
}}
style={

View File

@@ -1,13 +1,14 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
@@ -99,16 +100,20 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const [isStuck, setIsStuck] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
@@ -215,12 +220,36 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollContainerRef?.current;
if (!sentinel || !root || !activeTask) {
setIsStuck(false);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
},
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainerRef, activeTask]);
const scrollToCard = useCallback(() => {
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
@@ -248,67 +277,104 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
return (
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
<>
{/* Sentinel — zero-height element that IntersectionObserver watches */}
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
<div
className={cn(
"rounded-lg border transition-all duration-200",
isStuck
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
: "border-info/20 bg-info/5",
)}
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{activeTask.agent_id ? (
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
) : (
<Square className="h-3 w-3" />
<div className={cn(
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
)}>
<Bot className="h-3 w-3" />
</div>
)}
<span>Stop</span>
</button>
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
<span className="truncate">{name} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{!isStuck && toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
{isStuck ? (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
onClick={scrollToCard}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="Scroll to live card"
>
<ArrowDown className="h-3 w-3" />
Latest
<ChevronUp className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span>Stop</span>
</button>
)}
</div>
)}
</div>
{/* Timeline content — collapses when stuck */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
)}
>
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,7 +93,7 @@ function priorityLabel(priority: string): string {
function formatActivity(
entry: TimelineEntry,
resolveActorName?: (type: string, id: string) => string,
): ReactNode {
): string {
const details = (entry.details ?? {}) as Record<string, string>;
switch (entry.action) {
case "created":
@@ -121,24 +121,10 @@ function formatActivity(
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
case "description_updated":
return "updated the description";
case "task_completed": {
const label = details.trigger === "comment" ? "completed the follow-up" : "completed the task";
if (details.pr_url) {
return (
<>
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="text-foreground underline underline-offset-2 hover:text-foreground/80">
PR
</a>
</>
);
}
return label;
}
case "task_failed": {
if (details.error) return `task failed: ${details.error}`;
case "task_completed":
return "completed the task";
case "task_failed":
return "task failed";
}
default:
return entry.action ?? "";
}
@@ -527,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
@@ -756,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
})}
</CommandGroup>
)}
{agents.length > 0 && (
{agents.filter((a) => !a.archived_at).length > 0 && (
<CommandGroup heading="Agents">
{agents.map((a) => {
{agents.filter((a) => !a.archived_at).map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (
@@ -785,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Agent live output */}
<div className="mt-4">
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
/>
</div>
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
scrollContainerRef={scrollContainerRef}
/>
{/* Agent execution history */}
<div className="mt-3">

View File

@@ -162,7 +162,7 @@ function ActorSubContent({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>

View File

@@ -272,6 +272,39 @@ export const en: LandingDict = {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "Editor Overhaul & Agent Lifecycle",
changes: [
"Unified Tiptap editor with a single Markdown pipeline for editing and display",
"Reliable Markdown paste, inline code spacing, and link styling",
"Agent archive and restore — soft delete replaces hard delete",
"Archived agents hidden from default agent list",
"Skeleton loading states, error toasts, and confirmation dialogs across the app",
"OpenCode added as a supported agent provider",
"Reply-triggered agent tasks now inherit thread-root @mentions",
"Granular real-time event handling for issues and inbox — no more full refetches",
"Unified image upload flow for paste and button in the editor",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "Mentions & Permissions",
changes: [
"@mention issues in comments with server-side auto-expansion",
"@all mention to notify every workspace member",
"Inbox auto-scrolls to the referenced comment from a notification",
"Repositories extracted into a standalone settings tab",
"CLI update support from the web runtime page and direct download for non-Homebrew installs",
"CLI commands for viewing issue execution runs and run messages",
"Agent permission model — owners and admins manage agents, members manage skills on their own agents",
"Per-issue serial execution to prevent concurrent task collisions",
"File upload now supports all file types",
"README redesign with quickstart guide",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -272,6 +272,39 @@ export const zh: LandingDict = {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "编辑器重构与 Agent 生命周期",
changes: [
"统一 Tiptap 编辑器,编辑和展示共用单一 Markdown 渲染管线",
"Markdown 粘贴、行内代码间距和链接样式修复",
"Agent 支持归档和恢复——软删除替代硬删除",
"默认列表隐藏已归档的 Agent",
"全应用新增骨架屏加载态、错误提示和确认对话框",
"新增 OpenCode 作为支持的 Agent 提供商",
"回复触发的 Agent 任务自动继承主线程 @提及",
"Issue 和收件箱实时事件细粒度处理,不再全量刷新",
"编辑器中统一图片上传流程,支持粘贴和按钮上传",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "提及与权限",
changes: [
"评论中支持 @提及 Issue服务端自动展开",
"支持 @all 提及工作区所有成员",
"收件箱通知点击后自动滚动到对应评论",
"仓库管理独立为设置页单独标签页",
"支持从网页端运行时页面更新 CLI非 Homebrew 安装支持直接下载更新",
"新增 CLI 命令查看 Issue 执行记录和运行消息",
"Agent 权限模型优化——所有者和管理员管理 Agent成员可管理自己 Agent 的技能",
"每个 Issue 串行执行,防止并发任务冲突",
"文件上传支持所有文件类型",
"README 重新设计,新增快速入门指南",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId

View File

@@ -2,9 +2,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -24,10 +26,124 @@ var agentListCmd = &cobra.Command{
RunE: runAgentList,
}
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
var agentCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new agent",
RunE: runAgentCreate,
}
var agentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentUpdate,
}
var agentArchiveCmd = &cobra.Command{
Use: "archive <id>",
Short: "Archive an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentArchive,
}
var agentRestoreCmd = &cobra.Command{
Use: "restore <id>",
Short: "Restore an archived agent",
Args: cobra.ExactArgs(1),
RunE: runAgentRestore,
}
var agentTasksCmd = &cobra.Command{
Use: "tasks <id>",
Short: "List tasks for an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentTasks,
}
// Agent skills subcommands.
var agentSkillsCmd = &cobra.Command{
Use: "skills",
Short: "Manage agent skill assignments",
}
var agentSkillsListCmd = &cobra.Command{
Use: "list <agent-id>",
Short: "List skills assigned to an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsList,
}
var agentSkillsSetCmd = &cobra.Command{
Use: "set <agent-id>",
Short: "Set skills for an agent (replaces all current assignments)",
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsSet,
}
func init() {
agentCmd.AddCommand(agentListCmd)
agentCmd.AddCommand(agentGetCmd)
agentCmd.AddCommand(agentCreateCmd)
agentCmd.AddCommand(agentUpdateCmd)
agentCmd.AddCommand(agentArchiveCmd)
agentCmd.AddCommand(agentRestoreCmd)
agentCmd.AddCommand(agentTasksCmd)
agentCmd.AddCommand(agentSkillsCmd)
agentSkillsCmd.AddCommand(agentSkillsListCmd)
agentSkillsCmd.AddCommand(agentSkillsSetCmd)
// agent list
agentListCmd.Flags().String("output", "table", "Output format: table or json")
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
// agent get
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
// agent create
agentCreateCmd.Flags().String("name", "", "Agent name (required)")
agentCreateCmd.Flags().String("description", "", "Agent description")
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
// agent update
agentUpdateCmd.Flags().String("name", "", "New name")
agentUpdateCmd.Flags().String("description", "", "New description")
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
agentUpdateCmd.Flags().String("status", "", "New status")
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
agentUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// agent archive
agentArchiveCmd.Flags().String("output", "json", "Output format: table or json")
// agent restore
agentRestoreCmd.Flags().String("output", "json", "Output format: table or json")
// agent tasks
agentTasksCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills list
agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills set
agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)")
agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json")
}
// resolveProfile returns the --profile flag value (empty string means default profile).
@@ -90,6 +206,10 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
return cfg.WorkspaceID
}
// ---------------------------------------------------------------------------
// Agent commands
// ---------------------------------------------------------------------------
func runAgentList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
@@ -100,9 +220,16 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
defer cancel()
var agents []map[string]any
path := "/api/agents"
params := url.Values{}
if client.WorkspaceID != "" {
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
params.Set("workspace_id", client.WorkspaceID)
}
if v, _ := cmd.Flags().GetBool("include-archived"); v {
params.Set("include_archived", "true")
}
path := "/api/agents"
if len(params) > 0 {
path += "?" + params.Encode()
}
if err := client.GetJSON(ctx, path, &agents); err != nil {
return fmt.Errorf("list agents: %w", err)
@@ -113,20 +240,342 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
return cli.PrintJSON(os.Stdout, agents)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"}
rows := make([][]string, 0, len(agents))
for _, a := range agents {
archived := ""
if v := strVal(a, "archived_at"); v != "" {
archived = "yes"
}
rows = append(rows, []string{
strVal(a, "id"),
strVal(a, "name"),
strVal(a, "status"),
strVal(a, "runtime_mode"),
archived,
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var agent map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
return fmt.Errorf("get agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, agent)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"}
rows := [][]string{{
strVal(agent, "id"),
strVal(agent, "name"),
strVal(agent, "status"),
strVal(agent, "runtime_mode"),
strVal(agent, "visibility"),
strVal(agent, "description"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
runtimeID, _ := cmd.Flags().GetString("runtime-id")
if runtimeID == "" {
return fmt.Errorf("--runtime-id is required")
}
body := map[string]any{
"name": name,
"runtime_id": runtimeID,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("instructions"); v != "" {
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil {
return fmt.Errorf("create agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("instructions") {
v, _ := cmd.Flags().GetString("instructions")
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-id") {
v, _ := cmd.Flags().GetString("runtime-id")
body["runtime_id"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("status") {
v, _ := cmd.Flags().GetString("status")
body["status"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
return fmt.Errorf("update agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentArchive(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil {
return fmt.Errorf("archive agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentRestore(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil {
return fmt.Errorf("restore agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentTasks(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var tasks []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil {
return fmt.Errorf("list agent tasks: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, tasks)
}
headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"}
rows := make([][]string, 0, len(tasks))
for _, t := range tasks {
rows = append(rows, []string{
strVal(t, "id"),
strVal(t, "issue_id"),
strVal(t, "status"),
strVal(t, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
// ---------------------------------------------------------------------------
// Agent skills subcommands
// ---------------------------------------------------------------------------
func runAgentSkillsList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil {
return fmt.Errorf("list agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentSkillsSet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if !cmd.Flags().Changed("skill-ids") {
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)")
}
skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids")
// Allow passing empty string to clear all skills.
cleanIDs := make([]string, 0, len(skillIDs))
for _, id := range skillIDs {
id = strings.TrimSpace(id)
if id != "" {
cleanIDs = append(cleanIDs, id)
}
}
body := map[string]any{
"skill_ids": cleanIDs,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result json.RawMessage
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil {
return fmt.Errorf("set agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
var pretty any
json.Unmarshal(result, &pretty)
return cli.PrintJSON(os.Stdout, pretty)
}
fmt.Printf("Skills updated for agent %s\n", args[0])
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func strVal(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {

View File

@@ -0,0 +1,306 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Manage agent runtimes",
}
var runtimeListCmd = &cobra.Command{
Use: "list",
Short: "List runtimes in the workspace",
RunE: runRuntimeList,
}
var runtimeUsageCmd = &cobra.Command{
Use: "usage <runtime-id>",
Short: "Get token usage for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUsage,
}
var runtimeActivityCmd = &cobra.Command{
Use: "activity <runtime-id>",
Short: "Get hourly task activity for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeActivity,
}
var runtimePingCmd = &cobra.Command{
Use: "ping <runtime-id>",
Short: "Ping a runtime to check connectivity",
Args: cobra.ExactArgs(1),
RunE: runRuntimePing,
}
var runtimeUpdateCmd = &cobra.Command{
Use: "update <runtime-id>",
Short: "Initiate a CLI update on a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUpdate,
}
func init() {
runtimeCmd.AddCommand(runtimeListCmd)
runtimeCmd.AddCommand(runtimeUsageCmd)
runtimeCmd.AddCommand(runtimeActivityCmd)
runtimeCmd.AddCommand(runtimePingCmd)
runtimeCmd.AddCommand(runtimeUpdateCmd)
// runtime list
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
// runtime usage
runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json")
runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)")
// runtime activity
runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json")
// runtime ping
runtimePingCmd.Flags().String("output", "json", "Output format: table or json")
runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)")
// runtime update
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
}
// ---------------------------------------------------------------------------
// Runtime commands
// ---------------------------------------------------------------------------
func runRuntimeList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var runtimes []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
return fmt.Errorf("list runtimes: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, runtimes)
}
headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"}
rows := make([][]string, 0, len(runtimes))
for _, rt := range runtimes {
rows = append(rows, []string{
strVal(rt, "id"),
strVal(rt, "name"),
strVal(rt, "runtime_mode"),
strVal(rt, "provider"),
strVal(rt, "status"),
strVal(rt, "last_seen_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeUsage(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
days, _ := cmd.Flags().GetInt("days")
if days < 1 || days > 365 {
return fmt.Errorf("--days must be between 1 and 365")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var usage []map[string]any
path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days)
if err := client.GetJSON(ctx, path, &usage); err != nil {
return fmt.Errorf("get runtime usage: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, usage)
}
headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"}
rows := make([][]string, 0, len(usage))
for _, u := range usage {
rows = append(rows, []string{
strVal(u, "date"),
strVal(u, "provider"),
strVal(u, "model"),
strVal(u, "input_tokens"),
strVal(u, "output_tokens"),
strVal(u, "cache_read_tokens"),
strVal(u, "cache_write_tokens"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeActivity(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var activity []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil {
return fmt.Errorf("get runtime activity: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, activity)
}
headers := []string{"HOUR", "COUNT"}
rows := make([][]string, 0, len(activity))
for _, a := range activity {
rows = append(rows, []string{
strVal(a, "hour"),
strVal(a, "count"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimePing(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// Initiate ping.
var ping map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil {
return fmt.Errorf("initiate ping: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status"))
return nil
}
// Poll until completed/failed/timeout.
pingID := strVal(ping, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status"))
case <-time.After(1 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil {
return fmt.Errorf("get ping status: %w", err)
}
status := strVal(ping, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
if status == "completed" {
fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms"))
} else {
fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error"))
}
return nil
}
}
}
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
targetVersion, _ := cmd.Flags().GetString("target-version")
if targetVersion == "" {
return fmt.Errorf("--target-version is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
defer cancel()
body := map[string]any{
"target_version": targetVersion,
}
var update map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil {
return fmt.Errorf("initiate update: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status"))
return nil
}
// Poll until completed/failed/timeout.
updateID := strVal(update, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status"))
case <-time.After(2 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil {
return fmt.Errorf("get update status: %w", err)
}
status := strVal(update, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
if status == "completed" {
fmt.Printf("Update completed: %s\n", strVal(update, "output"))
} else {
fmt.Printf("Update %s: %s\n", status, strVal(update, "error"))
}
return nil
}
}
}

View File

@@ -0,0 +1,450 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Manage skills",
}
var skillListCmd = &cobra.Command{
Use: "list",
Short: "List skills in the workspace",
RunE: runSkillList,
}
var skillGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get skill details (includes files)",
Args: cobra.ExactArgs(1),
RunE: runSkillGet,
}
var skillCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new skill",
RunE: runSkillCreate,
}
var skillUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillUpdate,
}
var skillDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillDelete,
}
var skillImportCmd = &cobra.Command{
Use: "import",
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
RunE: runSkillImport,
}
// Skill file subcommands.
var skillFilesCmd = &cobra.Command{
Use: "files",
Short: "Manage skill files",
}
var skillFilesListCmd = &cobra.Command{
Use: "list <skill-id>",
Short: "List files for a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesList,
}
var skillFilesUpsertCmd = &cobra.Command{
Use: "upsert <skill-id>",
Short: "Create or update a skill file",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesUpsert,
}
var skillFilesDeleteCmd = &cobra.Command{
Use: "delete <skill-id> <file-id>",
Short: "Delete a skill file",
Args: cobra.ExactArgs(2),
RunE: runSkillFilesDelete,
}
func init() {
skillCmd.AddCommand(skillListCmd)
skillCmd.AddCommand(skillGetCmd)
skillCmd.AddCommand(skillCreateCmd)
skillCmd.AddCommand(skillUpdateCmd)
skillCmd.AddCommand(skillDeleteCmd)
skillCmd.AddCommand(skillImportCmd)
skillCmd.AddCommand(skillFilesCmd)
skillFilesCmd.AddCommand(skillFilesListCmd)
skillFilesCmd.AddCommand(skillFilesUpsertCmd)
skillFilesCmd.AddCommand(skillFilesDeleteCmd)
// skill list
skillListCmd.Flags().String("output", "table", "Output format: table or json")
// skill get
skillGetCmd.Flags().String("output", "json", "Output format: table or json")
// skill create
skillCreateCmd.Flags().String("name", "", "Skill name (required)")
skillCreateCmd.Flags().String("description", "", "Skill description")
skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)")
skillCreateCmd.Flags().String("config", "", "Skill config as JSON string")
skillCreateCmd.Flags().String("output", "json", "Output format: table or json")
// skill update
skillUpdateCmd.Flags().String("name", "", "New name")
skillUpdateCmd.Flags().String("description", "", "New description")
skillUpdateCmd.Flags().String("content", "", "New content")
skillUpdateCmd.Flags().String("config", "", "New config as JSON string")
skillUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// skill delete
skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill files list
skillFilesListCmd.Flags().String("output", "table", "Output format: table or json")
// skill files upsert
skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)")
skillFilesUpsertCmd.Flags().String("content", "", "File content (required)")
skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
// Skill commands
// ---------------------------------------------------------------------------
func runSkillList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil {
return fmt.Errorf("list skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
strVal(s, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skill map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil {
return fmt.Errorf("get skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skill)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := [][]string{{
strVal(skill, "id"),
strVal(skill, "name"),
strVal(skill, "description"),
strVal(skill, "created_at"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
body := map[string]any{
"name": name,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("content"); v != "" {
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil {
return fmt.Errorf("create skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("content") {
v, _ := cmd.Flags().GetString("content")
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --content, or --config")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil {
return fmt.Errorf("update skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillDelete(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0])
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return nil
}
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil {
return fmt.Errorf("delete skill: %w", err)
}
fmt.Printf("Skill deleted: %s\n", args[0])
return nil
}
func runSkillImport(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
importURL, _ := cmd.Flags().GetString("url")
if importURL == "" {
return fmt.Errorf("--url is required")
}
body := map[string]any{
"url": importURL,
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
return fmt.Errorf("import skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------
func runSkillFilesList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var files []map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil {
return fmt.Errorf("list skill files: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, files)
}
headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"}
rows := make([][]string, 0, len(files))
for _, f := range files {
rows = append(rows, []string{
strVal(f, "id"),
strVal(f, "path"),
strVal(f, "created_at"),
strVal(f, "updated_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillFilesUpsert(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
filePath, _ := cmd.Flags().GetString("path")
if filePath == "" {
return fmt.Errorf("--path is required")
}
content, _ := cmd.Flags().GetString("content")
if content == "" {
return fmt.Errorf("--content is required")
}
body := map[string]any{
"path": filePath,
"content": content,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil {
return fmt.Errorf("upsert skill file: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id"))
return nil
}
func runSkillFilesDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil {
return fmt.Errorf("delete skill file: %w", err)
}
fmt.Printf("Skill file deleted: %s\n", args[1])
return nil
}

View File

@@ -36,6 +36,8 @@ func init() {
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(runtimeCmd)
}
func main() {

View File

@@ -230,12 +230,11 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
}
agentID, _ := payload["agent_id"].(string)
issueID, _ := payload["issue_id"].(string)
taskID, _ := payload["task_id"].(string)
if issueID == "" {
return
}
// Look up issue to get workspace_id and title
// Look up issue to get workspace_id
issue, err := queries.GetIssue(ctx, parseUUID(issueID))
if err != nil {
slog.Error("activity: failed to get issue for task event",
@@ -243,46 +242,13 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
return
}
// Build enriched details from the task record
detailsMap := map[string]string{
"issue_title": issue.Title,
}
if taskID != "" {
if task, err := queries.GetAgentTask(ctx, parseUUID(taskID)); err == nil {
// Trigger type: comment-triggered vs assignment-triggered
if task.TriggerCommentID.Valid {
detailsMap["trigger"] = "comment"
}
if action == "task_completed" && len(task.Result) > 0 {
var completed protocol.TaskCompletedPayload
if err := json.Unmarshal(task.Result, &completed); err == nil {
if completed.PRURL != "" {
detailsMap["pr_url"] = completed.PRURL
}
}
}
if action == "task_failed" && task.Error.Valid && task.Error.String != "" {
// Truncate long error messages for the activity summary
errMsg := task.Error.String
if len(errMsg) > 200 {
errMsg = errMsg[:200] + "…"
}
detailsMap["error"] = errMsg
}
}
}
details, _ := json.Marshal(detailsMap)
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: parseUUID(issueID),
ActorType: util.StrToText("agent"),
ActorID: parseUUID(agentID),
Action: action,
Details: details,
Details: []byte("{}"),
})
if err != nil {
slog.Error("activity: failed to record task activity",

View File

@@ -308,15 +308,6 @@ func TestActivityTaskCompleted(t *testing.T) {
if util.UUIDToString(activities[0].ActorID) != agentID {
t.Fatalf("expected actor_id %s, got %s", agentID, util.UUIDToString(activities[0].ActorID))
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}
func TestActivityTaskFailed(t *testing.T) {
@@ -352,13 +343,4 @@ func TestActivityTaskFailed(t *testing.T) {
if activities[0].Action != "task_failed" {
t.Fatalf("expected action 'task_failed', got %q", activities[0].Action)
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}

View File

@@ -202,8 +202,16 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// is announcing to everyone, not specifically requesting work from the agent.
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
mentions := util.ParseMentions(content)
// Filter out issue mentions — they are cross-references, not @people.
filtered := mentions[:0]
for _, m := range mentions {
if m.Type != "issue" {
filtered = append(filtered, m)
}
}
mentions = filtered
if len(mentions) == 0 {
return false // No mentions — normal on_comment behavior
return false // No mentions (or only issue refs) — normal on_comment behavior
}
// @all is a broadcast to all members — suppress agent trigger.
if util.HasMentionAll(mentions) {

View File

@@ -86,6 +86,16 @@ func TestCommentMentionsOthersButNotAssignee(t *testing.T) {
content: fmt.Sprintf("[@All](mention://all/all) [@Agent](mention://agent/%s) fyi", agentAssigneeID),
want: true,
},
{
name: "issue mention only → allow trigger (cross-reference, not @person)",
content: "[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) is related",
want: false,
},
{
name: "issue mention + other agent → suppress (agent mention matters)",
content: fmt.Sprintf("[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) cc [@Other](mention://agent/%s)", otherAgentID),
want: true,
},
}
for _, tt := range tests {