Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ 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.12.0] - 2026-09-18

The workspace pilot grows a management surface: the agent knows which workspace it is linked to, the sidebar shows what has and has not synced, a `/workspace` menu handles refresh/sync/unlink, and a locally written skill can be published to the workspace. Everything under **Added** is pilot-only (`ALTIMATE_WORKSPACE=1`); nothing changes for other users. **Heads-up for support:** `upgrade` and `uninstall` now refuse when they cannot tell how the binary was installed, instead of guessing — see the first entry under **Fixed**.

### Added

- **The agent knows which workspace it is linked to.** The system prompt names the bound workspace whenever the binding is verified, so "which workspace is this project linked to?" is answerable; unverified states stay unnamed. Server-provided names are stripped of control and line-separator characters and length-bounded before they reach the prompt, so a hostile workspace name cannot open a new heading or role. (#1278)
- **`/workspace` menu** — shows the binding, then **Refresh** (pull the workspace's skills and memory into this project), **Sync** (re-send local memory the workspace never received) and **Unlink**. Unlink is safe against a relink that lands mid-request and confirms with the server before clearing local state. (#1278)
- **Sidebar sync status** — two lines under the workspace name: `12 memories · 3 not synced` and `skills synced 6m ago`. Refreshes every 30 s and reacts immediately to a link, unlink or rebind in this process. The memory-enablement check is rate-limited (once per five minutes on "no", never re-asked on "yes") and scoped to the signed-in account, so a tenant switch never pairs one account's counts with another's name. (#1279)
- **Publish a skill to the workspace** — `altimate-code skill publish <name>`, and a "Publish to workspace" action in the Skills dialog (`ctrl+a` on a skill). Uploads every file in the skill directory, not just `SKILL.md`; re-publishing updates the same workspace skill. Refuses, with a message that says what to do, when the project is not linked, the account does not own the workspace, the skill is built-in, global, or one the workspace sent you, a file is binary or a symlink, the bundle is empty or over 10 MB / 100 files, the name is taken by another of your skills, or the skill was edited in the workspace while you were uploading. Never uploads `.env*`, `.git`, editor swap files, private keys and certificates (`id_rsa`, `*.pem`, `*.key`, `*.p12`, …), `.npmrc`/`.netrc`/`.pypirc`, `credentials.json`, `secrets.*`, or the `.ssh`/`.aws`/`.gnupg`/`.altimate` directories — a filename blocklist, so keep other secrets out of skill folders. Documented in [Skills](docs/docs/configure/skills.md#cli-commands). (#1280, #1313)
- **Extension tools in the prompt** — when a live VS Code bridge for this project serves extension-type tools (dbt project tools, SQL tools), the `## Workspace integrations` section now names them, so the model can call what the IDE is actually serving. Silent unless both the catalog lists the tool and the bridge is verified alive. (#1291)

### Fixed

- **`upgrade` and `uninstall` resolve the install from the running binary** instead of asking every package manager and acting on the first that answered. When the method cannot be confirmed — a pinned `ALTIMATE_CODE_BIN_PATH`, an `npx`/`dlx` cache, a scoop or choco install (which only ever targeted upstream's `opencode` package), or an unfamiliar layout — both commands now refuse and print the manual command for each manager, rather than upgrading the wrong package or, for `uninstall`, deleting config, data and cache before failing to remove the binary. Upgrade failures name a reason; subprocess output reaching the log is redacted first. (#1305, #1306)
- **Compaction, title and summary requests no longer fail with "Could not get a response from the agent."** Those requests declare no tools while summarising a history full of tool calls, which the Altimate gateway rejects. Tool parts are now flattened to readable text for toolless requests only, with assistant turns coalesced so role alternation holds; ordinary turns are untouched. (#1319, closes #1315)
- **Creating a quick workspace from an already-linked project works.** The atomic create-and-bind call refused before creating anything, so the rebind path never had a target. It now creates unbound, then repoints — and aborts cleanly if the signed-in account changes in between, rather than stranding a new workspace. (#1318)
- **`skill publish` and the TUI "Publish to workspace" row appear only under the workspace pilot**, like `link`; outside it, the command told users to run a `link` command that did not exist for them. Found in this release's review.
- **`test/installation/ownership.test.ts` read the developer's real `BUN_INSTALL`** and was red on any machine where bun had ever installed this package globally. Found in this release's review.

## [0.11.2] - 2026-09-11

Patch on 0.11.1: closes the gap that kept most free-tier users from ever being offered Altimate Base, and makes linked workspace names clickable. **Heads-up for support:** on their next launch, users whose default quietly moved to a public free Zen model after 0.11.0 will now see a one-time dialog asking whether to switch to Altimate Base. Nothing switches without a Yes.
Expand Down
7 changes: 6 additions & 1 deletion docs/docs/configure/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,13 @@ altimate-code skill install owner/repo --global # install globally

# Remove
altimate-code skill remove my-tool # remove skill + paired tool

# Publish to the linked workspace (pilot, requires ALTIMATE_WORKSPACE=1)
altimate-code skill publish my-tool # upload every file in the skill directory; re-run to update
```

`skill publish` sends the whole skill directory, not just `SKILL.md`, so keep secrets out of it. A built-in filter skips known file and directory names — `.env*`, `.git`, `id_rsa`, `*.pem`, `*.key`, `*.p12`, `.npmrc`/`.netrc`, `credentials.json`, `secrets.*`, `.ssh`/`.aws`, editor swap files — but it matches names only and never scans file contents, so a token inside `config.yaml` or a key named `server.crt` would still be uploaded. Built-in skills, global skills and skills the workspace itself sent you cannot be published.

### TUI

Open the skill browser with `ctrl+i` when no other dialog is open, or type `/skills` in the prompt:
Expand All @@ -206,7 +211,7 @@ Open the skill browser with `ctrl+i` when no other dialog is open, or type `/ski
|-----|--------|
| `ctrl+i` | Open skill browser (when no dialog is open) / Install skill (when inside browser) |
| Enter | Use — inserts `/<skill-name>` into the prompt |
| `ctrl+a` | Actions — show, edit, test, or remove the selected skill |
| `ctrl+a` | Actions — show, edit, test, remove, or publish the selected skill to the linked workspace (the publish row appears only with `ALTIMATE_WORKSPACE=1`) |
| `ctrl+n` | New — scaffold a new skill + CLI tool |
| Esc | Back — returns to previous screen |

Expand Down
8 changes: 8 additions & 0 deletions docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ altimate --agent analyst
| `upgrade` | Upgrade to latest version |
| `uninstall` | Uninstall altimate |

### Workspaces (pilot)

Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it:

- `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Unlink** detaches the project.
- `altimate-code skill publish <name>` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands).

## Global Flags

| Flag | Description |
Expand Down
50 changes: 39 additions & 11 deletions packages/opencode/src/altimate/workspace/skill-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,41 @@ const MAX_BUNDLE_FILES = 100
* files the user did not mean to publish either.
*
* A blocklist, so incomplete by construction: it catches the common shapes,
* not every file that could hold a secret. A `credentials.json` ships. */
const NEVER_PUBLISH_DIRS = new Set([".git", "node_modules", "__pycache__"])
* not every file that could hold a secret. A `config.yaml` with a token in it
* ships. The credential shapes below are the ones that arrive by copy or by
* habit — a private key dropped next to a script, an `.npmrc` with a token —
* and are never part of a skill. */
const NEVER_PUBLISH_DIRS = new Set([".git", "node_modules", "__pycache__", ".ssh", ".aws", ".gnupg", ".altimate"])
const NEVER_PUBLISH_NAMES = new Set([
".git",
".ds_store",
"thumbs.db",
".env",
".envrc",
".npmrc",
".netrc",
".pypirc",
".htpasswd",
"credentials.json",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
])
const NEVER_PUBLISH_SUFFIXES = [
"~",
".swp",
".swo",
".pem",
".key",
".p12",
".pfx",
".jks",
".keystore",
".ppk",
".kdbx",
".secret",
]
function isJunkFile(name: string): boolean {
// Case-folded: Windows and macOS file systems are case-insensitive by
// default, so `.ENV` is the same file as `.env` there and must not slip
Expand All @@ -78,15 +111,10 @@ function isJunkFile(name: string): boolean {
return (
// A worktree's `.git` is a regular FILE pointing at the main repository,
// not a directory — so the directory skip alone did not cover it.
lower === ".git" ||
lower === ".ds_store" ||
lower === "thumbs.db" ||
lower === ".env" ||
lower === ".envrc" ||
NEVER_PUBLISH_NAMES.has(lower) ||
lower.startsWith(".env.") ||
lower.endsWith("~") ||
lower.endsWith(".swp") ||
lower.endsWith(".swo")
lower.startsWith("secrets.") ||
NEVER_PUBLISH_SUFFIXES.some((suffix) => lower.endsWith(suffix))
)
}
/** The shared request budget is 15s and covers the upload itself; a legal 10MB
Expand Down Expand Up @@ -270,7 +298,7 @@ export async function collectBundle(dir: string): Promise<BundleFile[]> {
const full = path.join(current, entry.name)
const relative = path.relative(root, full).split(path.sep).join("/")
if (entry.isDirectory()) {
if (NEVER_PUBLISH_DIRS.has(entry.name)) continue
if (NEVER_PUBLISH_DIRS.has(entry.name.toLowerCase())) continue
await walk(full)
continue
}
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,9 +512,9 @@ export async function createThenBindOrRebind(
// Already linked: that same atomicity makes it unusable. ``create_and_bind``
// pre-checks the identifiers and 409s *before* creating anything, so the
// rebind below never got a target and this row simply always failed — with
// an error telling the user to re-run the command they were already inside
// Create unbound first, then repoint, which is what the row's
// own hint promises.
// an error telling the user to re-run the command they were already inside.
// Create unbound first, then repoint, which is what the row's own hint
// promises.
if (existing) {
const ws = await WorkspaceApi.createWorkspaceUnbound({ name })
created = { via: "unbound", datamate: ws }
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/cli/cmd/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers
// altimate_change start — telemetry for skill operations
import { Telemetry } from "@/altimate/telemetry"
import { describePublish, explainPublishError, publishSkill } from "@/altimate/workspace/skill-publish"
import { Flag } from "@opencode-ai/core/flag/flag"
// altimate_change end

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -228,6 +229,9 @@ const SkillListCommand = cmd({
process.stdout.write(EOL)
process.stdout.write(`${skills.length} skill(s) found.` + EOL)
process.stdout.write(`Create a new skill: altimate-code skill create <name>` + EOL)
if (Flag.ALTIMATE_WORKSPACE) {
Comment thread
sahrizvi marked this conversation as resolved.
process.stdout.write(`Share one with your workspace: altimate-code skill publish <name>` + EOL)
}
})
},
})
Expand Down Expand Up @@ -809,7 +813,9 @@ export const SkillCommand = cmd({
.command(SkillListCommand)
.command(SkillCreateCommand)
.command(SkillTestCommand)
.command(SkillPublishCommand)
// Gated like `link` (src/index.ts): a user outside the pilot would be
// told to run a `link` command that is not registered for them.
.command(Flag.ALTIMATE_WORKSPACE ? [SkillPublishCommand] : [])
.command(SkillShowCommand)
.command(SkillInstallCommand)
.command(SkillRemoveCommand)
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/plugin/tui/altimate/skill-ops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { createMemo, createResource, createSignal, Show } from "solid-js"
import { detectToolReferences, skillSource } from "@/cli/cmd/skill-helpers"
import { describePublish, explainPublishError, isManagedSkill, publishSkill } from "@/altimate/workspace/skill-publish"
import { Telemetry } from "@/altimate/telemetry"
import { Flag } from "@opencode-ai/core/flag/flag"
import { spawn } from "child_process"
import os from "os"
import path from "path"
Expand Down Expand Up @@ -588,7 +589,10 @@ function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillN
title: "Publish to workspace",
value: "publish",
description: "Upload this skill to the linked workspace so your team gets it",
disabled: isBuiltin || isGlobal || managed,
// Pilot-gated like the CLI's `skill publish` and the Workspace plugin
// itself: outside the pilot there is no `link`, so the row could only
// ever fail with "not linked".
disabled: !Flag.ALTIMATE_WORKSPACE || isBuiltin || isGlobal || managed,
},
{ title: "Remove", value: "remove", description: "Delete this skill and its paired tool", disabled: !removable },
] as TuiDialogSelectOption<string>[]
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/test/altimate/workspace/skill-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,44 @@ describe("the bundle size guard", () => {
expect(files.map((f) => f.path)).toEqual(["SKILL.md"])
})

test("credential files that arrive by copy or habit never leave the machine", async () => {
// Filename-shaped, so a `config.yaml` holding a token still ships — this
// closes the shapes support tickets name, not the class. Every entry is
// its own file so a dropped pattern fails on that name, not on the set.
const names = [
"id_rsa",
"ID_ED25519", // case-insensitive file systems
"server.pem",
"client.key",
"cert.p12",
"cert.pfx",
"trust.jks",
"app.keystore",
"login.ppk",
"vault.kdbx",
"db.secret",
"secrets.yaml",
"credentials.json",
".npmrc",
".netrc",
".pypirc",
".htpasswd",
]
for (const name of names) writeFileSync(path.join(skillDir, name), "-----BEGIN PRIVATE KEY-----")
for (const dir of [".ssh", ".AWS", ".gnupg", ".altimate"]) {
mkdirSync(path.join(skillDir, dir), { recursive: true })
writeFileSync(path.join(skillDir, dir, "config"), "token")
}
// The shapes a skill legitimately carries stay: a public key is not a
// secret, and a `.keys.md` is prose about keys.
writeFileSync(path.join(skillDir, "id_rsa.pub"), "ssh-ed25519 AAAA")
writeFileSync(path.join(skillDir, "api-keys.md"), "# Where keys live")

const files = await collectBundle(skillDir)

expect(files.map((f) => f.path)).toEqual(["api-keys.md", "id_rsa.pub", "SKILL.md"])
})

test("a worktree's .git file is junk too, not only a .git directory", async () => {
// `git worktree add` leaves a regular file named `.git` holding
// `gitdir: /path/to/main/.git/worktrees/...`. The directory skip does not
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/installation/ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ describe("bunGlobalRoot", () => {
test("derives the package tree from the shim directory", () => {
// `bun pm bin -g` reports the SHIM dir; packages live in a sibling tree. Conflating the
// two rejected every global bun install as "not-global".
expect(bunGlobalRoot("/home/u/.bun/bin")).toBe("/home/u/.bun/install/global/node_modules")
expect(bunGlobalRoot("/home/u/.bun/bin", {})).toBe("/home/u/.bun/install/global/node_modules")
})

test("a bun global binary is inside the derived root", () => {
const root = bunGlobalRoot("/home/u/.bun/bin")
const root = bunGlobalRoot("/home/u/.bun/bin", {})
const exec = "/home/u/.bun/install/global/node_modules/@altimateai/altimate-code/bin/altimate-code"
// The regression: the shim dir does NOT contain the executable, the package root does.
expect(exec.startsWith("/home/u/.bun/bin")).toBe(false)
Expand Down
Loading
Loading