diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 156537e..b4cf86c 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -3,6 +3,7 @@ name: Publish to PyPI on: push: branches: [release] + workflow_dispatch: concurrency: group: pypi-${{ github.ref }} @@ -12,11 +13,12 @@ permissions: contents: read jobs: - build: + preflight: runs-on: ubuntu-latest outputs: - skip: ${{ steps.check.outputs.skip }} version: ${{ steps.pkg.outputs.version }} + publish_needed: ${{ steps.pypi.outputs.publish_needed }} + release_assets_needed: ${{ steps.release.outputs.release_assets_needed }} steps: - name: Checkout uses: actions/checkout@v4 @@ -32,19 +34,63 @@ jobs: ") echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Check if version already on PyPI - id: check + - name: Check PyPI + id: pypi + env: + VERSION: ${{ steps.pkg.outputs.version }} run: | - VERSION="${{ steps.pkg.outputs.version }}" - if pip index versions cptr 2>/dev/null | grep -q "$VERSION"; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Version $VERSION already on PyPI — skipping" - else - echo "skip=false" >> $GITHUB_OUTPUT - fi + python3 - <<'PY' + import os + import urllib.error + import urllib.request + + version = os.environ["VERSION"] + exists = False + try: + with urllib.request.urlopen(f"https://pypi.org/pypi/cptr/{version}/json", timeout=15): + exists = True + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"publish_needed={'false' if exists else 'true'}\n") + print(f"Version {version} {'already exists on PyPI' if exists else 'is not on PyPI'}") + PY + + - name: Check GitHub release assets + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.pkg.outputs.version }} + run: | + set -euo pipefail + expected=( + "cptr-${VERSION}-py3-none-any.whl" + "cptr-${VERSION}-linux-wheelhouse.tar.gz" + "SHA256SUMS" + ) + + assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name' 2>/dev/null || true)" + missing=false + for name in "${expected[@]}"; do + if ! grep -Fx "$name" <<< "$assets" >/dev/null; then + missing=true + echo "Missing release asset: $name" + fi + done + + echo "release_assets_needed=$missing" >> "$GITHUB_OUTPUT" + + build: + needs: preflight + if: github.ref == 'refs/heads/release' && (needs.preflight.outputs.publish_needed == 'true' || needs.preflight.outputs.release_assets_needed == 'true') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - name: Set up Node.js - if: steps.check.outputs.skip != 'true' uses: actions/setup-node@v4 with: node-version: "22" @@ -52,60 +98,53 @@ jobs: cache-dependency-path: cptr/frontend/package-lock.json - name: Build frontend - if: steps.check.outputs.skip != 'true' working-directory: cptr/frontend run: | npm ci npm run build - name: Set up Python - if: steps.check.outputs.skip != 'true' uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install build tools - if: steps.check.outputs.skip != 'true' run: pip install hatchling build - name: Build package - if: steps.check.outputs.skip != 'true' run: python -m build --wheel - name: Build Linux wheelhouse - if: steps.check.outputs.skip != 'true' run: | - VERSION="${{ steps.pkg.outputs.version }}" + VERSION="${{ needs.preflight.outputs.version }}" WHEEL="dist/cptr-${VERSION}-py3-none-any.whl" + cp "$WHEEL" . mkdir -p wheelhouse python -m pip download --dest wheelhouse "${WHEEL}[all]" tar -czf "cptr-${VERSION}-linux-wheelhouse.tar.gz" -C wheelhouse . - name: Generate checksums - if: steps.check.outputs.skip != 'true' run: | - sha256sum dist/*.whl cptr-*-linux-wheelhouse.tar.gz > SHA256SUMS + sha256sum cptr-*.whl cptr-*-linux-wheelhouse.tar.gz > SHA256SUMS - name: Upload dist artifacts - if: steps.check.outputs.skip != 'true' uses: actions/upload-artifact@v4 with: name: dist path: dist/ - name: Upload release artifacts - if: steps.check.outputs.skip != 'true' uses: actions/upload-artifact@v4 with: name: release-assets path: | - dist/*.whl + cptr-*.whl cptr-*-linux-wheelhouse.tar.gz SHA256SUMS publish: - needs: build - if: needs.build.outputs.skip != 'true' + needs: [preflight, build] + if: github.ref == 'refs/heads/release' && needs.preflight.outputs.publish_needed == 'true' runs-on: ubuntu-latest environment: pypi permissions: @@ -117,14 +156,39 @@ jobs: name: dist path: dist/ + - name: Check if version already on PyPI + id: check + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + python3 - <<'PY' + import os + import urllib.error + import urllib.request + + version = os.environ["VERSION"] + exists = False + try: + with urllib.request.urlopen(f"https://pypi.org/pypi/cptr/{version}/json", timeout=15): + exists = True + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"skip={'true' if exists else 'false'}\n") + print(f"Version {version} {'already exists on PyPI, skipping publish' if exists else 'is not on PyPI'}") + PY + - name: Publish to PyPI + if: steps.check.outputs.skip != 'true' uses: pypa/gh-action-pypi-publish@release/v1 # No API token needed - uses OIDC trusted publishing. # Configure at: https://pypi.org/manage/project/cptr/settings/publishing/ github-release-assets: - needs: build - if: needs.build.outputs.skip != 'true' + needs: [preflight, build] + if: github.ref == 'refs/heads/release' && needs.preflight.outputs.release_assets_needed == 'true' runs-on: ubuntu-latest permissions: contents: write @@ -138,11 +202,31 @@ jobs: - name: Attach GitHub release artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.build.outputs.version }} + VERSION: ${{ needs.preflight.outputs.version }} run: | - for attempt in {1..12}; do + set -euo pipefail + ls -lah release-assets + mapfile -t files < <(find release-assets -maxdepth 1 -type f -print | sort) + if [ "${#files[@]}" -eq 0 ]; then + echo "No release asset files found" >&2 + exit 1 + fi + + for attempt in {1..60}; do if gh release view "v${VERSION}" >/dev/null 2>&1; then - gh release upload "v${VERSION}" release-assets/* --clobber + gh release upload "v${VERSION}" "${files[@]}" --clobber + + assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name')" + for expected in \ + "cptr-${VERSION}-py3-none-any.whl" \ + "cptr-${VERSION}-linux-wheelhouse.tar.gz" \ + "SHA256SUMS" + do + if ! grep -Fx "$expected" <<< "$assets" >/dev/null; then + echo "Missing release asset after upload: $expected" >&2 + exit 1 + fi + done exit 0 fi sleep 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index e90f12a..601c818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-07-06 + +### Added + +- 🧰 **Create skills from chat.** Use `/skills:create` to turn a workflow into a reusable skill for the current workspace. +- 📚 **Skill list in chat.** Use `/skills:list` to see available skills, including which ones Computer can manage directly. +- 📄 **Inline file previews.** Files can now open inside chat, including images, PDFs, documents, text, Markdown, JSON, CSV, HTML, SVG, audio, and video. + +### Changed + +- 🖼️ **Generated images appear as files.** New images are saved to the workspace and displayed in chat with the same preview controls as other files. +- 📦 **More reliable release downloads.** Release runs can refill missing download files and checksums without republishing an existing package. + ## [0.7.7] - 2026-07-06 ### Added diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index 00ae9ae..e8c9e6e 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -378,11 +378,5 @@ /* Tippy tooltip theme */ .tippy-box[data-theme~='cptr'] { - background: #1a1a1a; - color: #e0e0e0; - font-size: 0.6875rem; - font-weight: 500; - padding: 0.125rem 0.25rem; - border-radius: 0.375rem; - box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3); + @apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl; } diff --git a/cptr/frontend/src/lib/apis/skills.ts b/cptr/frontend/src/lib/apis/skills.ts index 82025c1..4bdc386 100644 --- a/cptr/frontend/src/lib/apis/skills.ts +++ b/cptr/frontend/src/lib/apis/skills.ts @@ -10,6 +10,7 @@ export interface SkillInfo { source: string; // "workspace" | "global" license?: string; compatibility?: string; + managed?: boolean; } export const getSkills = (workspace: string) => diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 0a00b29..448dca5 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -3,11 +3,14 @@ import { get } from 'svelte/store'; import MarkdownRenderer from '$lib/components/markdown/MarkdownRenderer.svelte'; import OutputEditView from './OutputEditView.svelte'; + import ChatFilePreview from './ChatFilePreview.svelte'; import ConsecutiveActivityGroup from './ConsecutiveActivityGroup.svelte'; import ReasoningCollapsible from './ReasoningCollapsible.svelte'; import ToolCallCollapsible from './ToolCallCollapsible.svelte'; import { currentWorkspace, openFileTab } from '$lib/stores'; import { ttsConfigured, ttsEnabled } from '$lib/stores/audio'; + import { tooltip } from '$lib/tooltip'; + import { fileIconName } from '$lib/utils/fileIcon'; import Icon from '../Icon.svelte'; import { t } from '$lib/i18n'; @@ -49,6 +52,7 @@ let editedOutput = $state(null); let copied = $state(false); let showUsageTooltip = $state(false); + let collapsedFiles = $state>({}); let textareaEl: HTMLTextAreaElement; async function startEdit() { @@ -138,6 +142,17 @@ return parts[parts.length - 1]; } + function resolvedFilePath(file: any): string { + const path = String(file?.full_path || file?.path || ''); + if (!path || path.startsWith('/')) return path; + const workspace = file?.workspace || get(currentWorkspace)?.path || ''; + return workspace ? `${workspace.replace(/\/$/, '')}/${path.replace(/^\/+/, '')}` : path; + } + + function toggleFile(key: string) { + collapsedFiles = { ...collapsedFiles, [key]: !collapsedFiles[key] }; + } + /** Human-readable label for a tool call */ function toolLabel(name: string, args: any): string { const _t = $t; @@ -159,6 +174,8 @@ return _t('chat.tool.createFile', { path: shortPath(args.path) }); case 'write_file': return _t('chat.tool.writeFile', { path: shortPath(args.path) }); + case 'display_file': + return `Display ${shortPath(args.path)}`; case 'list_directory': return args.recursive ? _t('chat.tool.listDirectoryRecursive', { path: shortPath(args.path) }) @@ -234,7 +251,13 @@ item: any; } - type DisplayItem = ActivityGroup | MessageItem | ArtifactItem | ImageItem; + interface FileItem { + type: 'file_item'; + item: any; + index: number; + } + + type DisplayItem = ActivityGroup | MessageItem | ArtifactItem | ImageItem | FileItem; const outputText = $derived.by((): string => { return (output || []) @@ -313,7 +336,7 @@ } }; - for (const item of output) { + for (const [index, item] of output.entries()) { if (item.type === 'function_call') { ensureGroup(); currentGroup!.entries.push(item); @@ -335,6 +358,9 @@ } else if (item.type === 'image') { flushGroup(); items.push({ type: 'image_item', item }); + } else if (item.type === 'file') { + flushGroup(); + items.push({ type: 'file_item', item, index }); } // function_call_output items are handled via outputMap, skip standalone render } @@ -469,6 +495,53 @@ {/each} + {:else if displayItem.type === 'file_item'} + {@const file = displayItem.item} + {@const filePath = resolvedFilePath(file)} + {@const fileKey = filePath || file.path || `file-${displayItem.index}`} + {@const collapsed = Boolean(collapsedFiles[fileKey])} +
+
+ + +
+ {#if !collapsed} + + {/if} +
{:else if displayItem.type === 'activity_group'} {#if displayItem.entries.length === 1} {@const item = displayItem.entries[0]} diff --git a/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte b/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte new file mode 100644 index 0000000..23db9d4 --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte @@ -0,0 +1,210 @@ + + +{#if loading} +
Loading...
+{:else if error} +
{error}
+{:else if kind === 'image'} +
+ +
+{:else if kind === 'pdf'} +
+ +
+{:else if kind === 'office'} +
+ +
+{:else if kind === 'html' && content} +
+ +
+{:else if kind === 'svg' && content} +
+ +
+{:else if kind === 'markdown' && content} +
+ +
+{:else if kind === 'json' && jsonData !== null} +
+ +
+{:else if kind === 'csv' && content} +
+ +
+{:else if kind === 'text' && content} +
{content}
+{:else if kind === 'audio'} +
+ +
+{:else if kind === 'video'} +
+ + +
+{/if} + + diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 83374e6..c7efd5d 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -39,6 +39,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import { t } from '$lib/i18n'; import { TAB_DRAG_MIME } from '$lib/constants'; + import { tooltip } from '$lib/tooltip'; // Keep screen awake during voice mode conversations let voiceWakeLock: WakeLockSentinel | null = null; @@ -73,6 +74,7 @@ oncompact?: () => void; onplan?: () => void; onstatus?: () => void; + onskillslist?: () => void; oncancel?: () => void; onqueuesendnow?: (id: string) => void; onqueueedit?: (id: string) => void; @@ -91,6 +93,7 @@ oncompact, onplan, onstatus, + onskillslist, oncancel, onqueuesendnow, onqueueedit, @@ -927,6 +930,14 @@ if (oncompact && '/compact'.startsWith(slashCommandQuery)) ids.push('compact'); if (onplan && '/plan'.startsWith(slashCommandQuery)) ids.push('plan'); if (onstatus && '/status'.startsWith(slashCommandQuery)) ids.push('status'); + if ( + onskillslist && + slashCommandQuery !== '/skills:list' && + '/skills:list'.startsWith(slashCommandQuery) + ) + ids.push('skills:list'); + if (slashCommandQuery !== '/skills:create' && '/skills:create'.startsWith(slashCommandQuery)) + ids.push('skills:create'); return ids; }); const showSlashCommands = $derived(slashCommandIds.length > 0); @@ -955,6 +966,15 @@ onstatus(); return; } + if (commandId === 'skills:list' && onskillslist) { + inputText = ''; + onskillslist(); + return; + } + if (commandId === 'skills:create') { + inputText = '/skills:create '; + return; + } onsend(); } @@ -1010,6 +1030,8 @@ {#if slashCommandIds.includes('compact')} {/if} + {#if slashCommandIds.includes('skills:list')} + + {/if} + {#if slashCommandIds.includes('skills:create')} + + {/if} {/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 7e813ce..dd2fb3c 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -53,7 +53,9 @@ import AssistantMessage from './AssistantMessage.svelte'; import ChatHistory from './ChatHistory.svelte'; import StatusModal from './StatusModal.svelte'; + import SkillsModal from './SkillsModal.svelte'; import { listCommandSessions, type CommandSession } from '$lib/apis/terminal'; + import { getSkills, type SkillInfo } from '$lib/apis/skills'; import Spinner from '../common/Spinner.svelte'; import Icon from '../Icon.svelte'; import { toast } from 'svelte-sonner'; @@ -79,6 +81,8 @@ let currentMessageId = $state(null); let contextUsage = $state(null); let showStatusModal = $state(false); + let showSkillsModal = $state(false); + let skillsModalList = $state([]); let commandSessions = $state([]); let initialCommandSessionId = $state(null); let previousChats = $state([]); @@ -686,6 +690,11 @@ inputText = ''; return; } + if (text === '/skills:list') { + await handleSkillsListCommand(); + inputText = ''; + return; + } stopTtsPlayback(); if (shouldStreamTts()) void unlockTtsAudioPlayback(); sending = true; @@ -865,6 +874,15 @@ refreshCommandSessions(); } + async function handleSkillsListCommand() { + try { + skillsModalList = await getSkills(workspace); + showSkillsModal = true; + } catch (err: any) { + toast.error(err?.message || 'Failed to load skills'); + } + } + async function refreshCommandSessions() { const currentChatId = chatId; if (!currentChatId) { @@ -1576,6 +1594,7 @@ oncompact={handleManualCompact} onplan={handlePlanCommand} onstatus={handleStatusCommand} + onskillslist={handleSkillsListCommand} oncancel={handleCancel} {queuedMessages} onqueuesendnow={handleQueueSendNow} @@ -1601,3 +1620,13 @@ }} /> {/if} + +{#if showSkillsModal} + { + showSkillsModal = false; + skillsModalList = []; + }} + /> +{/if} diff --git a/cptr/frontend/src/lib/components/chat/SkillsModal.svelte b/cptr/frontend/src/lib/components/chat/SkillsModal.svelte new file mode 100644 index 0000000..d550b47 --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/SkillsModal.svelte @@ -0,0 +1,55 @@ + + + +
+ +
+ Skills +
+
+ +
+ {#if skills.length} +
+ {#each skills as item} +
+
+
+ {item.name} +
+
+ {item.managed ? 'managed' : 'read-only'} +
+
+
+ {item.description} +
+
+ {/each} +
+ {:else} +
No skills found
+ {/if} +
+
diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index e0af4cf..c03087c 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging from pathlib import Path from typing import List, Optional @@ -679,7 +680,7 @@ async def approve_tool(chat_id: str, message_id: str, body: ApproveRequest, requ ) # Emit artifact card if the tool produced an artifact - from cptr.utils.chat_task import build_artifact_item, build_image_item + from cptr.utils.chat_task import build_artifact_item artifact_item = build_artifact_item(call["name"], call.get("arguments", {}), result) if artifact_item: @@ -691,15 +692,19 @@ async def approve_tool(chat_id: str, message_id: str, body: ApproveRequest, requ {"chat_id": chat_id, "message_id": message_id, "output": artifact_item}, ) - image_item = build_image_item(call["name"], result) - if image_item: - output.append(image_item) - from cptr.socket.main import emit_to_user - - await emit_to_user( - user_id, - {"chat_id": chat_id, "message_id": message_id, "output": image_item}, - ) + if call["name"] == "display_file": + try: + file_item = json.loads(result) + except (json.JSONDecodeError, TypeError): + file_item = None + if isinstance(file_item, dict) and file_item.get("type") == "file": + output.append(file_item) + from cptr.socket.main import emit_to_user + + await emit_to_user( + user_id, + {"chat_id": chat_id, "message_id": message_id, "output": file_item}, + ) await ChatMessage.update(message_id, output=output, done=False) diff --git a/cptr/routers/skills.py b/cptr/routers/skills.py index 72546f7..5780b11 100644 --- a/cptr/routers/skills.py +++ b/cptr/routers/skills.py @@ -18,6 +18,7 @@ class SkillResponse(BaseModel): source: str # "workspace" or "global" license: Optional[str] = None compatibility: Optional[str] = None + managed: bool = False @router.get("", response_model=list[SkillResponse]) @@ -34,6 +35,7 @@ async def list_skills(workspace: str = Query(..., description="Workspace path")) source=s.source, license=s.license, compatibility=s.compatibility, + managed=s.managed, ) for s in skills ] diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 27a0961..5ca4957 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -50,6 +50,104 @@ "tools or implementing." ) +SKILLS_CREATE_RE = re.compile(r"^/skills:create(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) + +COMPUTER_SKILL_AUTHORING_STANDARDS = """\ +Follow the Computer skill-authoring standards: + +Frontmatter: +- name: lowercase-hyphenated, <=64 chars, no spaces. +- description: one sentence, <=60 characters, ends with a period. State the + capability, not the implementation. Do not repeat the skill name. Avoid + marketing words like powerful, comprehensive, seamless, advanced, or robust. + Count the characters before saving. +- version: 0.1.0. +- platforms: declare [macos], [linux], or [windows] only when the skill uses + OS-bound primitives. Omit it for portable skills. + +Body section order: +1. "# " plus a short intro covering what it does, what it does not + do, and important dependency assumptions. +2. "## When to Use" with concrete trigger phrases. +3. "## Prerequisites" with exact env vars, credentials, install steps, or "None". +4. "## How to Run" with the canonical workflow framed through Computer tools. +5. "## Quick Reference" with flat commands, routes, files, or APIs. +6. "## Procedure" with numbered, copy-paste-exact steps. +7. "## Pitfalls" with known limits and failure modes. +8. "## Verification" with one focused check that proves the skill works. + +Computer-tool framing: +- Reference Computer tools by name in backticks: `read_file`, `list_directory`, + `search_files`, `web_search`, `read_url`, `run_command`, `view_skill`, + and `manage_skill`. +- Frame shell work as "run through `run_command`". +- Prefer Computer tools in prose over raw shell utilities when a tool exists: + say `read_file` instead of cat/head/tail, `search_files` instead of grep/rg/find, + and `read_url` instead of curl-to-scrape. +- Third-party CLIs are fine inside procedures or scripts, but explain that the + agent invokes them through `run_command`. + +Quality bar: +- Prefer exact commands, routes, file paths, function names, config keys, and + error text found verbatim in the sources. Do not invent flags, paths, APIs, + or behavior. +- Keep SKILL.md tight and scannable: about 100 lines for a simple workflow, + about 200 for a complex one. +- Do not create a router/index/hub skill that only points at other skills. +- Put larger reusable scripts in `scripts/`, detailed docs in `references/`, + reusable outputs in `templates/`, and binary or visual assets in `assets/`. +- New skills default to workspace scope. Do not use scope="global" unless the + user explicitly asks for a global skill.""" + + +def _build_skill_create_prompt(user_request: str) -> str: + req = (user_request or "").strip() + if not req: + req = ( + "the workflow we just went through in this conversation - review the " + "steps taken and distill them into a reusable skill" + ) + return ( + "[/skills:create] The user wants you to create a reusable Computer skill " + "from the request below and save it.\n\n" + f"THE REQUEST:\n{req}\n\n" + "The request is open-ended and may mix SOURCES to gather (directories, " + "file paths, URLs, what we just did, pasted notes) and REQUIREMENTS that " + "shape the skill (focus, exclusions, scope, naming, style, constraints). " + "Treat every part of the request as load-bearing. Prose after a path or " + "URL is not incidental; it is authoring guidance. Never fetch the first " + "source and ignore the rest.\n\n" + "Do this:\n" + "1. Gather every source the user named with the tools you already have: " + "`read_file`/`search_files`/`list_directory` for local files or directories, " + "`web_search`/`read_url` for web sources, the current conversation if they " + "refer to what just happened, and pasted text as-is. If scope is ambiguous, " + "make a reasonable choice and note it; do not stall.\n" + "2. Apply every requirement, focus, and constraint in the request to what " + "the skill covers and emphasizes.\n" + "3. Author one SKILL.md using the standards below.\n" + '4. Save it with manage_skill(action="create", name=..., content=...). ' + "If the skill needs supporting files, add them after the create with " + 'manage_skill(action="write_file", name=..., file_path=..., ' + "file_content=...).\n\n" + f"{COMPUTER_SKILL_AUTHORING_STANDARDS}\n\n" + "When done, tell the user the skill name, location, and one-line summary." + ) + + +def _apply_skills_create_prompt(messages: list[dict]) -> bool: + for message in reversed(messages): + if message.get("role") != "user": + continue + text = _plain_message_text(message.get("content")) + match = SKILLS_CREATE_RE.match(text.strip()) + if not match: + return False + message["content"] = _build_skill_create_prompt(match.group(1) or "") + return True + return False + + # ── Task registry ─────────────────────────────────────────── _tasks: dict[str, asyncio.Task] = {} # message_id → asyncio.Task @@ -778,21 +876,33 @@ def _tool_result_for_model(tool_name: str, result: str) -> str: if not isinstance(images, list): return result + image_files = [ + { + "path": image.get("path"), + "name": image.get("name"), + "content_type": image.get("content_type"), + } + for image in images + if isinstance(image, dict) and image.get("path") + ] + return json.dumps( { "status": meta.get("status", "success"), - "displayed_to_user": True, - "note": "The generated image is already displayed in the chat. Do not include markdown image links for it.", - "images": [ + "displayed_to_user": False, + "must_call": "display_file", + "instruction": ( + "Do not answer the user yet. The image file is saved but not rendered. " + "Call display_file once for each path below." + ), + "display_file_calls": [ { - "id": image.get("id"), - "path": image.get("path"), - "name": image.get("name"), - "mime_type": image.get("mime_type"), + "name": "display_file", + "arguments": {"path": image["path"]}, } - for image in images - if isinstance(image, dict) + for image in image_files ], + "images": image_files, } ) @@ -1101,30 +1211,6 @@ def build_artifact_item(tool_name: str, arguments: dict, result: str) -> dict | } -def build_image_item(tool_name: str, result: str) -> dict | None: - """Build a renderable image output item for image-generation tools.""" - if tool_name != "image_generate": - return None - - try: - meta = json.loads(result) - except (json.JSONDecodeError, TypeError): - return None - - images = meta.get("images") - if not isinstance(images, list) or not images: - return None - - return { - "type": "image", - "images": [ - image - for image in images - if isinstance(image, dict) and isinstance(image.get("url"), str) - ], - } - - def _default_base_url(provider: str) -> str: return { "anthropic": "https://api.anthropic.com/v1", @@ -1284,6 +1370,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): chat_obj = await Chat.get_by_id(chat_id) chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {} messages, loaded_summary = await _load_message_history(chat_id, message_id) + _apply_skills_create_prompt(messages) memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt) system = await _load_system_prompt( workspace, @@ -1545,6 +1632,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {} configured_model = (msg.model if msg else None) or model messages, loaded_summary = await _load_message_history(chat_id, message_id) + _apply_skills_create_prompt(messages) memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt) system = await _load_system_prompt( workspace, @@ -1958,11 +2046,15 @@ async def _run_agent_target(agent_target: AgentModelTarget): await emit(output=artifact_item) _sync_state() - image_item = build_image_item(tc["name"], result) - if image_item: - output_items.append(image_item) - await emit(output=image_item) - _sync_state() + if tc["name"] == "display_file": + try: + file_item = json.loads(result) + except (json.JSONDecodeError, TypeError): + file_item = None + if isinstance(file_item, dict) and file_item.get("type") == "file": + output_items.append(file_item) + await emit(output=file_item) + _sync_state() sequential_results.append((tc, result)) diff --git a/cptr/utils/prompt_templates.py b/cptr/utils/prompt_templates.py index 7ea728f..8d82e95 100644 --- a/cptr/utils/prompt_templates.py +++ b/cptr/utils/prompt_templates.py @@ -168,6 +168,7 @@ def _format_cptr_context(workspace: str, model: str = "") -> str: "Tool behavior:", f"- {host_control}", "- Use the available tools before claiming you cannot inspect or change something.", + "- If the user asks to show a file in chat, use display_file.", "- For machine-level requests such as volume, brightness, apps, services, packages, " "network state, or files, check the runtime and use appropriate shell commands or " "configured tools when available.", diff --git a/cptr/utils/skills.py b/cptr/utils/skills.py index a9a6a28..3fa9259 100644 --- a/cptr/utils/skills.py +++ b/cptr/utils/skills.py @@ -17,6 +17,7 @@ import logging import re from dataclasses import dataclass, field +from typing import Literal from pathlib import Path import yaml @@ -39,6 +40,10 @@ str(Path.home() / ".cptr" / "skills"), ] +MANAGED_WORKSPACE_SKILL_DIR = ".cptr/skills" +MANAGED_GLOBAL_SKILL_DIR = Path.home() / ".cptr" / "skills" +ALLOWED_BUNDLE_DIRS = {"references", "templates", "scripts", "assets"} + # Directories to skip during scanning _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv"} @@ -53,21 +58,25 @@ @dataclass class SkillMeta: """Tier 1: lightweight metadata for the catalog.""" + name: str description: str - location: str # absolute path to SKILL.md - source: str # "workspace" or "global" + location: str # absolute path to SKILL.md + source: str # "workspace" or "global" # Optional spec fields license: str | None = None compatibility: str | None = None metadata: dict | None = None allowed_tools: str | None = None + managed: bool = False @dataclass class SkillContent(SkillMeta): """Tier 2: full instructions + resource listing.""" - body: str = "" # markdown body after frontmatter + + content: str = "" # full SKILL.md, including frontmatter + body: str = "" # markdown body after frontmatter resources: list[str] = field(default_factory=list) # relative paths of bundled files @@ -108,7 +117,7 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: # If value contains a colon and isn't already quoted if ":" in val and not (val.startswith('"') or val.startswith("'")): indent = len(line) - len(line.lstrip()) - fixed_lines.append(f"{' ' * indent}{key_part.strip()}: \"{val}\"") + fixed_lines.append(f'{" " * indent}{key_part.strip()}: "{val}"') continue fixed_lines.append(line) @@ -149,7 +158,9 @@ def validate_name(name: str, parent_dir: str) -> list[str]: warnings.append(f"name exceeds 64 characters ({len(name)})") if not _NAME_RE.match(name): - warnings.append(f"name '{name}' contains invalid characters (must be lowercase alphanumeric + hyphens)") + warnings.append( + f"name '{name}' contains invalid characters (must be lowercase alphanumeric + hyphens)" + ) if "--" in name: warnings.append(f"name '{name}' contains consecutive hyphens") @@ -163,7 +174,22 @@ def validate_name(name: str, parent_dir: str) -> list[str]: # ── Discovery ─────────────────────────────────────────────── -def _scan_skills_dir(skills_dir: Path, source: str) -> list[SkillMeta]: +def _is_managed_skill(skill_md: Path, workspace: str = "") -> bool: + try: + skill_dir = skill_md.parent.resolve() + global_root = MANAGED_GLOBAL_SKILL_DIR.expanduser().resolve() + if skill_dir.is_relative_to(global_root): + return True + if workspace: + workspace_root = (Path(workspace) / MANAGED_WORKSPACE_SKILL_DIR).resolve() + if skill_dir.is_relative_to(workspace_root): + return True + except OSError: + pass + return False + + +def _scan_skills_dir(skills_dir: Path, source: str, workspace: str = "") -> list[SkillMeta]: """Scan a single skills directory for skill folders containing SKILL.md.""" results: list[SkillMeta] = [] @@ -211,16 +237,19 @@ def _scan_skills_dir(skills_dir: Path, source: str) -> list[SkillMeta]: for w in warnings: logger.warning("[skills] %s: %s", skill_md, w) - results.append(SkillMeta( - name=name, - description=description[:1024], - location=str(skill_md.resolve()), - source=source, - license=fm.get("license"), - compatibility=fm.get("compatibility"), - metadata=fm.get("metadata") if isinstance(fm.get("metadata"), dict) else None, - allowed_tools=fm.get("allowed-tools"), - )) + results.append( + SkillMeta( + name=name, + description=description[:1024], + location=str(skill_md.resolve()), + source=source, + license=fm.get("license"), + compatibility=fm.get("compatibility"), + metadata=fm.get("metadata") if isinstance(fm.get("metadata"), dict) else None, + allowed_tools=fm.get("allowed-tools"), + managed=_is_managed_skill(skill_md, workspace), + ) + ) except PermissionError: logger.debug("[skills] Permission denied scanning %s", skills_dir) @@ -245,20 +274,21 @@ def discover_skills(workspace: str) -> list[SkillMeta]: if ws.is_dir(): for rel_dir in WORKSPACE_SKILL_DIRS: skills_dir = ws / rel_dir - for skill in _scan_skills_dir(skills_dir, source="workspace"): + for skill in _scan_skills_dir(skills_dir, source="workspace", workspace=workspace): if skill.name not in seen_names: seen_names.add(skill.name) all_skills.append(skill) else: logger.debug( "[skills] Skipping duplicate '%s' from %s (already found)", - skill.name, skill.location, + skill.name, + skill.location, ) # 2. Global skills (user-level, available across all workspaces) for dir_path in GLOBAL_SKILL_DIRS: skills_dir = Path(dir_path).expanduser() - for skill in _scan_skills_dir(skills_dir, source="global"): + for skill in _scan_skills_dir(skills_dir, source="global", workspace=workspace): if skill.name not in seen_names: seen_names.add(skill.name) all_skills.append(skill) @@ -337,11 +367,101 @@ def load_skill(workspace: str, skill_name: str) -> SkillContent | None: compatibility=meta.compatibility, metadata=meta.metadata, allowed_tools=meta.allowed_tools, + managed=meta.managed, + content=text, body=body, resources=resources, ) +def _managed_root(workspace: str, scope: Literal["workspace", "global"]) -> Path: + if scope == "global": + return MANAGED_GLOBAL_SKILL_DIR.expanduser() + if not workspace: + raise ValueError("workspace is required for workspace skills") + return Path(workspace).expanduser().resolve() / MANAGED_WORKSPACE_SKILL_DIR + + +def _validate_skill_name(name: str) -> str: + normalized = (name or "").strip() + warnings = validate_name(normalized, normalized) + if warnings: + raise ValueError("; ".join(warnings)) + return normalized + + +def _validate_skill_content(name: str, content: str) -> tuple[dict, str]: + if not content or not content.strip(): + raise ValueError("content is required") + fm, body = parse_frontmatter(content) + skill_name = str(fm.get("name", "")).strip() + description = str(fm.get("description", "")).strip() + if skill_name != name: + raise ValueError("frontmatter name must match skill name") + if not description: + raise ValueError("frontmatter description is required") + if not body: + raise ValueError("skill body is required") + return fm, body + + +def _write_text_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + + +def create_managed_skill( + workspace: str, + name: str, + content: str, + scope: Literal["workspace", "global"] = "workspace", +) -> dict: + name = _validate_skill_name(name) + _validate_skill_content(name, content) + if any(skill.name == name for skill in discover_skills(workspace)): + raise ValueError(f"skill '{name}' already exists") + skill_dir = _managed_root(workspace, scope) / name + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + raise ValueError(f"skill '{name}' already exists") + _write_text_atomic(skill_md, content.strip() + "\n") + return {"success": True, "name": name, "location": str(skill_md.resolve())} + + +def _managed_skill_dir(workspace: str, name: str) -> Path: + name = _validate_skill_name(name) + skill = next((s for s in discover_skills(workspace) if s.name == name), None) + if not skill: + raise ValueError(f"skill '{name}' not found") + if not skill.managed: + raise ValueError(f"skill '{name}' is read-only in Computer") + return Path(skill.location).parent.resolve() + + +def write_managed_skill_file( + workspace: str, name: str, file_path: str, file_content: str | None +) -> dict: + if file_content is None: + raise ValueError("file_content is required") + rel = Path(file_path or "") + if not rel.parts: + raise ValueError("file_path is required") + if rel.is_absolute() or ".." in rel.parts: + raise ValueError("file_path must stay inside the skill") + if rel.parts[0] not in ALLOWED_BUNDLE_DIRS: + raise ValueError("file_path must start with references/, templates/, scripts/, or assets/") + if any(part.startswith(".") for part in rel.parts): + raise ValueError("dot paths are not allowed") + skill_dir = _managed_skill_dir(workspace, name) + target = (skill_dir / rel).resolve() + if not target.is_relative_to(skill_dir): + raise ValueError("file_path escapes the skill") + _write_text_atomic(target, file_content) + return {"success": True, "name": name, "location": str(target)} + + # ── Catalog & structured output ───────────────────────────── diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 62f3cd2..1abb9a1 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -14,6 +14,7 @@ import asyncio import inspect import json +import mimetypes import os import time import uuid @@ -233,6 +234,37 @@ def _human_size(size: int) -> str: return f"{size:.1f}TB" +def _file_kind(path: Path, mime_type: str) -> str: + ext = path.suffix.lower() + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("audio/"): + return "audio" + if mime_type.startswith("video/"): + return "video" + if mime_type == "application/pdf": + return "pdf" + if ext in {".md", ".markdown", ".mdx"}: + return "markdown" + if ext in {".json", ".jsonc", ".json5"}: + return "json" + if ext in {".csv", ".tsv"}: + return "csv" + if ext in {".html", ".htm"}: + return "html" + if ext == ".svg": + return "svg" + if ext in {".txt", ".log", ".diff", ".patch"} or mime_type.startswith("text/"): + return "text" + if ext in {".docx", ".xlsx", ".xls", ".pptx"}: + return "office" + if ext in {".sqlite", ".sqlite3", ".db", ".db3"}: + return "sqlite" + if ext in {".zip", ".tar", ".gz", ".tgz", ".rar", ".7z"}: + return "archive" + return "binary" + + def _truncate_output(text: str, max_chars: int = 80_000) -> str: """Truncate long output, keeping head and tail.""" if len(text) <= max_chars: @@ -816,6 +848,48 @@ def _write(): return f"Wrote {len(content)} bytes to {path}" +async def display_file(path: str, *, workspace: str) -> str: + """Display a workspace file inline in chat. + Use when the user asks to see, preview, render, or display a file you created or found. + :param path: Path relative to workspace root. + """ + if not path: + return "Error: path is required." + + try: + full = _resolve_path(path, workspace) + except ValueError as exc: + return f"Error: {exc}" + + if _is_dotenv(full): + return _DOTENV_ERROR + if not full.exists(): + return f"Error: file not found: {path}" + if not full.is_file(): + return f"Error: not a file: {path}" + + stat = await asyncio.to_thread(full.stat) + mime_type = mimetypes.guess_type(str(full))[0] or "application/octet-stream" + ws = Path(workspace).resolve() + try: + display_path = str(full.relative_to(ws)) + except ValueError: + display_path = str(full) + return json.dumps( + { + "type": "file", + "path": display_path, + "full_path": str(full), + "workspace": str(ws), + "name": full.name, + "size": stat.st_size, + "mime_type": mime_type, + "kind": _file_kind(full, mime_type), + }, + ensure_ascii=False, + ) + + async def edit_file( path: str, target: str, @@ -1421,6 +1495,43 @@ async def view_skill( return format_skill_content(skill) +async def manage_skill( + action: Literal["create", "write_file"], + name: str, + content: Optional[str] = None, + scope: Literal["workspace", "global"] = "workspace", + file_path: Optional[str] = None, + file_content: Optional[str] = None, + *, + __context__: dict, +) -> str: + """Create Computer-managed skills and supporting bundle files. + + Use this only when the user asks to create a reusable skill. New skills + default to the current workspace. For supporting files, write only under + references/, templates/, scripts/, or assets/. + :param action: "create" to write SKILL.md, or "write_file" to add a bundle file. + :param name: Lowercase hyphenated skill name. + :param content: Full SKILL.md content for action="create". + :param scope: "workspace" for .cptr/skills, or "global" for ~/.cptr/skills. + :param file_path: Relative bundle path for action="write_file". + :param file_content: File content for action="write_file". + """ + from cptr.utils.skills import create_managed_skill, write_managed_skill_file + + workspace = __context__.get("workspace", "") + try: + if action == "create": + result = create_managed_skill(workspace, name, content or "", scope) + elif action == "write_file": + result = write_managed_skill_file(workspace, name, file_path or "", file_content) + else: + result = {"success": False, "error": f"unsupported action '{action}'"} + except Exception as e: + result = {"success": False, "error": str(e)} + return json.dumps(result, ensure_ascii=False) + + # ── Browser tools ──────────────────────────────────────────── @@ -1589,6 +1700,8 @@ async def image_generate( __context__: dict, ) -> str: """Generate or edit image files from a prompt. + Returns saved image file paths. You must call display_file next for each returned path + before responding to the user. :param prompt: Detailed description of the image to create or the edits to make. :param image: Optional source image file id, /api/files/... URL, or workspace path for edit mode. :param images: Optional source image file ids, /api/files/... URLs, or workspace paths for edit mode. @@ -1922,6 +2035,7 @@ async def notify(message: str, target: str = "", title: str = "", *, __context__ "view_skill": {"fn": view_skill, "auto": True}, # Write / mutate (require approval unless auto_approve_all) "create_file": {"fn": create_file, "auto": False}, + "display_file": {"fn": display_file, "auto": False}, "edit_file": {"fn": edit_file, "auto": False}, "multi_edit_file": {"fn": multi_edit_file, "auto": False}, "write_file": {"fn": write_file, "auto": False}, @@ -1934,6 +2048,7 @@ async def notify(message: str, target: str = "", title: str = "", *, __context__ "delete_automation": {"fn": delete_automation, "auto": False}, "notify": {"fn": notify, "auto": False}, "image_generate": {"fn": image_generate, "auto": False}, + "manage_skill": {"fn": manage_skill, "auto": False}, "update_memory": {"fn": update_memory, "auto": True}, } diff --git a/pyproject.toml b/pyproject.toml index 3067b3a..8cdc66d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.7.7" +version = "0.8.0" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 57adc99..73e731c 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.7.7" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },