feat: projects as backend sources with system prompt injection (#8739)
Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,6 @@ import { selectProjects } from "@/features/projects/stores/projectSelectors";
|
||||
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
|
||||
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
|
||||
import {
|
||||
buildProjectSystemPrompt,
|
||||
composeSystemPrompt,
|
||||
resolveProjectDefaultArtifactRoot,
|
||||
} from "@/features/projects/lib/chatProjectContext";
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
useResolvedAgentModelPicker,
|
||||
type PreferredModelSelection,
|
||||
} from "./useResolvedAgentModelPicker";
|
||||
import { updateSessionProject } from "@/shared/api/acpApi";
|
||||
|
||||
interface UseChatSessionControllerOptions {
|
||||
sessionId: string | null;
|
||||
@@ -147,22 +147,14 @@ export function useChatSessionController({
|
||||
})),
|
||||
[projects],
|
||||
);
|
||||
const projectSystemPrompt = useMemo(
|
||||
() => buildProjectSystemPrompt(project),
|
||||
[project],
|
||||
);
|
||||
const workingContextPrompt = useMemo(() => {
|
||||
if (!activeWorkspace?.branch) return undefined;
|
||||
return `<active-working-context>\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n</active-working-context>`;
|
||||
}, [activeWorkspace?.branch, activeWorkspace?.path]);
|
||||
const effectiveSystemPrompt = useMemo(
|
||||
() =>
|
||||
composeSystemPrompt(
|
||||
selectedPersona?.systemPrompt,
|
||||
projectSystemPrompt,
|
||||
workingContextPrompt,
|
||||
),
|
||||
[projectSystemPrompt, selectedPersona?.systemPrompt, workingContextPrompt],
|
||||
composeSystemPrompt(selectedPersona?.systemPrompt, workingContextPrompt),
|
||||
[selectedPersona?.systemPrompt, workingContextPrompt],
|
||||
);
|
||||
|
||||
const prepareCurrentSession = useCallback(
|
||||
@@ -332,6 +324,9 @@ export function useChatSessionController({
|
||||
null);
|
||||
|
||||
useChatSessionStore.getState().patchSession(sessionId, { projectId });
|
||||
|
||||
void updateSessionProject(sessionId, projectId).catch(console.error);
|
||||
|
||||
if (!selectedProvider) {
|
||||
return;
|
||||
}
|
||||
@@ -735,6 +730,9 @@ export function useChatSessionController({
|
||||
}
|
||||
if (hasPendingProject) {
|
||||
patch.projectId = nextProjectId ?? null;
|
||||
void updateSessionProject(sessionId, nextProjectId ?? null).catch(
|
||||
console.error,
|
||||
);
|
||||
}
|
||||
|
||||
useChatSessionStore.getState().patchSession(sessionId, patch);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string;
|
||||
/** Stable on-disk path of the project source. Pass back to update/delete. */
|
||||
path: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
@@ -13,8 +16,86 @@ export interface ProjectInfo {
|
||||
useWorktrees: boolean;
|
||||
order: number;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Shape returned by _goose/sources/*. Narrowed to project-type sources here.
|
||||
interface SourceEntry {
|
||||
type: "project";
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
path: string;
|
||||
global: boolean;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toProjectInfo(source: SourceEntry): ProjectInfo {
|
||||
const p = source.properties ?? {};
|
||||
return {
|
||||
id: source.name,
|
||||
path: source.path,
|
||||
name: (p.title as string) ?? source.name,
|
||||
description: source.description,
|
||||
prompt: source.content,
|
||||
icon: (p.icon as string) ?? "",
|
||||
color: (p.color as string) ?? "",
|
||||
preferredProvider: (p.preferredProvider as string) ?? null,
|
||||
preferredModel: (p.preferredModel as string) ?? null,
|
||||
workingDirs: (p.workingDirs as string[]) ?? [],
|
||||
useWorktrees: (p.useWorktrees as boolean) ?? false,
|
||||
order: (p.order as number) ?? 0,
|
||||
archivedAt: (p.archivedAt as string) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectMetadataFields {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
preferredProvider: string | null;
|
||||
preferredModel: string | null;
|
||||
workingDirs: string[];
|
||||
useWorktrees: boolean;
|
||||
order: number;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
function toProperties(info: ProjectMetadataFields): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
if (info.name) props.title = info.name;
|
||||
if (info.icon) props.icon = info.icon;
|
||||
if (info.color) props.color = info.color;
|
||||
if (info.preferredProvider) props.preferredProvider = info.preferredProvider;
|
||||
if (info.preferredModel) props.preferredModel = info.preferredModel;
|
||||
if (info.workingDirs?.length) props.workingDirs = info.workingDirs;
|
||||
if (info.useWorktrees) props.useWorktrees = info.useWorktrees;
|
||||
if (typeof info.order === "number") props.order = info.order;
|
||||
if (info.archivedAt) props.archivedAt = info.archivedAt;
|
||||
return props;
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "project";
|
||||
}
|
||||
|
||||
/** Pick a slug for `name` that does not collide with any existing project ID
|
||||
* (active or archived). Two display names that normalize to the same slug
|
||||
* (e.g. "My App" and "my-app", or both collapsing to "project" because they
|
||||
* contain no ASCII alphanumerics) are disambiguated with a numeric suffix. */
|
||||
function uniqueProjectSlug(name: string, existingIds: Set<string>): string {
|
||||
const base = slugify(name);
|
||||
if (!existingIds.has(base)) {
|
||||
return base;
|
||||
}
|
||||
let counter = 2;
|
||||
while (existingIds.has(`${base}-${counter}`)) {
|
||||
counter += 1;
|
||||
}
|
||||
return `${base}-${counter}`;
|
||||
}
|
||||
|
||||
export interface ProjectIconCandidate {
|
||||
@@ -29,7 +110,15 @@ export interface ProjectIconData {
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<ProjectInfo[]> {
|
||||
return invoke("list_projects");
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/list", {
|
||||
type: "project",
|
||||
});
|
||||
const sources = (raw.sources ?? []) as SourceEntry[];
|
||||
return sources
|
||||
.map(toProjectInfo)
|
||||
.filter((p) => p.archivedAt === null)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export async function scanProjectIcons(
|
||||
@@ -53,67 +142,112 @@ export async function createProject(
|
||||
workingDirs: string[],
|
||||
useWorktrees: boolean,
|
||||
): Promise<ProjectInfo> {
|
||||
return invoke("create_project", {
|
||||
name,
|
||||
const client = await getClient();
|
||||
const existing = await listAllProjects();
|
||||
const id = uniqueProjectSlug(name, new Set(existing.map((p) => p.id)));
|
||||
const raw = await client.extMethod("_goose/sources/create", {
|
||||
type: "project",
|
||||
name: id,
|
||||
description,
|
||||
prompt,
|
||||
icon,
|
||||
color,
|
||||
preferredProvider,
|
||||
preferredModel,
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
content: prompt,
|
||||
global: true,
|
||||
properties: toProperties({
|
||||
name,
|
||||
icon,
|
||||
color,
|
||||
preferredProvider,
|
||||
preferredModel,
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
}),
|
||||
});
|
||||
return toProjectInfo(raw.source as SourceEntry);
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
prompt: string,
|
||||
icon: string,
|
||||
color: string,
|
||||
preferredProvider: string | null,
|
||||
preferredModel: string | null,
|
||||
workingDirs: string[],
|
||||
useWorktrees: boolean,
|
||||
existing: ProjectInfo,
|
||||
updates: Partial<Omit<ProjectInfo, "id" | "path">>,
|
||||
): Promise<ProjectInfo> {
|
||||
return invoke("update_project", {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
icon,
|
||||
color,
|
||||
preferredProvider,
|
||||
preferredModel,
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
const merged = { ...existing, ...updates };
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/update", {
|
||||
type: "project",
|
||||
path: existing.path,
|
||||
name: existing.id,
|
||||
description: merged.description,
|
||||
content: merged.prompt,
|
||||
properties: toProperties({
|
||||
name: merged.name,
|
||||
icon: merged.icon,
|
||||
color: merged.color,
|
||||
preferredProvider: merged.preferredProvider,
|
||||
preferredModel: merged.preferredModel,
|
||||
workingDirs: merged.workingDirs,
|
||||
useWorktrees: merged.useWorktrees,
|
||||
order: merged.order,
|
||||
archivedAt: merged.archivedAt,
|
||||
}),
|
||||
});
|
||||
return toProjectInfo(raw.source as SourceEntry);
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
idOrProject: string | ProjectInfo,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
const path =
|
||||
typeof idOrProject === "string"
|
||||
? (await getProject(idOrProject)).path
|
||||
: idOrProject.path;
|
||||
await client.extMethod("_goose/sources/delete", {
|
||||
type: "project",
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
return invoke("delete_project", { id });
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<ProjectInfo> {
|
||||
return invoke("get_project", { id });
|
||||
const all = await listAllProjects();
|
||||
const match = all.find((p) => p.id === id);
|
||||
if (!match) throw new Error(`Project "${id}" not found`);
|
||||
return match;
|
||||
}
|
||||
|
||||
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
|
||||
return invoke("list_archived_projects");
|
||||
/** List both archived and active projects. */
|
||||
async function listAllProjects(): Promise<ProjectInfo[]> {
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/list", {
|
||||
type: "project",
|
||||
});
|
||||
const sources = (raw.sources ?? []) as SourceEntry[];
|
||||
return sources.map(toProjectInfo);
|
||||
}
|
||||
|
||||
export async function archiveProject(id: string): Promise<void> {
|
||||
return invoke("archive_project", { id });
|
||||
const project = await getProject(id);
|
||||
await updateProject(project, {
|
||||
archivedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreProject(id: string): Promise<void> {
|
||||
const project = await getProject(id);
|
||||
await updateProject(project, { archivedAt: null });
|
||||
}
|
||||
|
||||
export async function reorderProjects(
|
||||
order: [string, number][],
|
||||
): Promise<void> {
|
||||
return invoke("reorder_projects", { order });
|
||||
const all = await listAllProjects();
|
||||
for (const [id, orderValue] of order) {
|
||||
const existing = all.find((p) => p.id === id);
|
||||
if (!existing) continue;
|
||||
await updateProject(existing, { order: orderValue });
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreProject(id: string): Promise<void> {
|
||||
return invoke("restore_project", { id });
|
||||
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
|
||||
const all = await listAllProjects();
|
||||
return all.filter((p) => p.archivedAt !== null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildProjectSystemPrompt,
|
||||
composeSystemPrompt,
|
||||
getProjectArtifactRoots,
|
||||
getProjectFolderName,
|
||||
@@ -8,44 +7,6 @@ import {
|
||||
} from "./chatProjectContext";
|
||||
|
||||
describe("chatProjectContext", () => {
|
||||
it("builds project instructions from stored project settings", () => {
|
||||
const systemPrompt = buildProjectSystemPrompt({
|
||||
id: "project-1",
|
||||
name: "Goose2",
|
||||
description: "Desktop app",
|
||||
prompt: "Always read AGENTS.md before editing.",
|
||||
icon: "folder",
|
||||
color: "#000000",
|
||||
preferredProvider: "goose",
|
||||
preferredModel: "claude-sonnet-4",
|
||||
workingDirs: ["/Users/wesb/dev/goose2"],
|
||||
useWorktrees: true,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "now",
|
||||
updatedAt: "now",
|
||||
});
|
||||
|
||||
expect(systemPrompt).toContain("<project-settings>");
|
||||
expect(systemPrompt).toContain("Project name: Goose2");
|
||||
expect(systemPrompt).toContain(
|
||||
"Working directories: /Users/wesb/dev/goose2",
|
||||
);
|
||||
expect(systemPrompt).toContain(
|
||||
"Default working directory: /Users/wesb/dev/goose2",
|
||||
);
|
||||
expect(systemPrompt).toContain("Preferred provider: goose");
|
||||
expect(systemPrompt).toContain(
|
||||
"Use git worktrees for branch isolation: yes",
|
||||
);
|
||||
expect(systemPrompt).toContain("<project-file-policy>");
|
||||
expect(systemPrompt).toContain(
|
||||
"Use /Users/wesb/dev/goose2 as the default working directory for this project.",
|
||||
);
|
||||
expect(systemPrompt).toContain("<project-instructions>");
|
||||
expect(systemPrompt).toContain("Always read AGENTS.md before editing.");
|
||||
});
|
||||
|
||||
it("combines persona and project prompts without empty sections", () => {
|
||||
expect(
|
||||
composeSystemPrompt("Persona prompt", undefined, "Project prompt"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProjectInfo } from "../api/projects";
|
||||
import { resolvePath } from "@/shared/api/pathResolver";
|
||||
|
||||
export interface ProjectFolderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,7 +37,7 @@ export function getProjectArtifactRoots(
|
||||
}
|
||||
|
||||
export function resolveProjectDefaultArtifactRoot(
|
||||
project: ProjectInfo | null | undefined,
|
||||
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
|
||||
): string | undefined {
|
||||
const workingDirs = resolveProjectRoots(project);
|
||||
return workingDirs[0];
|
||||
@@ -56,61 +57,6 @@ export function getProjectFolderOption(
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildProjectSystemPrompt(
|
||||
project: ProjectInfo | null | undefined,
|
||||
): string | undefined {
|
||||
if (!project) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workingDir = resolveProjectDefaultArtifactRoot(project);
|
||||
const settings: string[] = [`Project name: ${project.name}`];
|
||||
const description = trimValue(project.description);
|
||||
const workingDirs = resolveProjectRoots(project);
|
||||
const prompt = trimValue(project.prompt);
|
||||
|
||||
if (description) {
|
||||
settings.push(`Project description: ${description}`);
|
||||
}
|
||||
if (workingDirs.length > 0) {
|
||||
settings.push(`Working directories: ${workingDirs.join(", ")}`);
|
||||
}
|
||||
if (workingDir) {
|
||||
settings.push(`Default working directory: ${workingDir}`);
|
||||
}
|
||||
if (project.preferredProvider) {
|
||||
settings.push(`Preferred provider: ${project.preferredProvider}`);
|
||||
}
|
||||
if (project.preferredModel) {
|
||||
settings.push(`Preferred model: ${project.preferredModel}`);
|
||||
}
|
||||
settings.push(
|
||||
`Use git worktrees for branch isolation: ${
|
||||
project.useWorktrees ? "yes" : "no"
|
||||
}`,
|
||||
);
|
||||
|
||||
const sections = [
|
||||
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
|
||||
];
|
||||
|
||||
if (workingDir) {
|
||||
sections.push(
|
||||
`<project-file-policy>\n` +
|
||||
`Use ${workingDir} as the default working directory for this project.\n` +
|
||||
`Write newly generated files relative to ${workingDir} by default.\n` +
|
||||
`Only write outside ${workingDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
|
||||
`</project-file-policy>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
sections.push(`<project-instructions>\n${prompt}\n</project-instructions>`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export function composeSystemPrompt(
|
||||
...parts: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("@/shared/api/pathResolver", () => ({
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "project-1",
|
||||
path: "/tmp/projects/project-1.md",
|
||||
name: "Project",
|
||||
description: "",
|
||||
prompt: "",
|
||||
@@ -25,8 +26,6 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
useWorktrees: false,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,8 +125,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
) => {
|
||||
const project = await updateProject(
|
||||
id,
|
||||
const existing = get().projects.find((p) => p.id === id);
|
||||
if (!existing) throw new Error(`Project ${id} not found`);
|
||||
const project = await updateProject(existing, {
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
@@ -136,7 +137,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
preferredModel,
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
);
|
||||
});
|
||||
set((state) => ({
|
||||
projects: state.projects.map((p) => (p.id === id ? project : p)),
|
||||
}));
|
||||
|
||||
@@ -188,18 +188,17 @@ export function CreateProjectDialog({
|
||||
try {
|
||||
let savedProject: ProjectInfo;
|
||||
if (isEditing) {
|
||||
savedProject = await updateProject(
|
||||
editingProject.id,
|
||||
name.trim(),
|
||||
"",
|
||||
parsedPrompt,
|
||||
savedProject = await updateProject(editingProject, {
|
||||
name: name.trim(),
|
||||
description: "",
|
||||
prompt: parsedPrompt,
|
||||
icon,
|
||||
color,
|
||||
preferredProvider || null,
|
||||
preferredProvider: preferredProvider || null,
|
||||
preferredModel,
|
||||
workingDirs,
|
||||
useWorktrees,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
savedProject = await createProject(
|
||||
name.trim(),
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/shared/api/system", () => ({
|
||||
vi.mock("../../api/projects", () => ({
|
||||
createProject: vi.fn().mockResolvedValue({
|
||||
id: "new-1",
|
||||
path: "/tmp/projects/new-1.md",
|
||||
name: "Test",
|
||||
description: "",
|
||||
prompt: "",
|
||||
@@ -39,11 +40,10 @@ vi.mock("../../api/projects", () => ({
|
||||
useWorktrees: false,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
}),
|
||||
updateProject: vi.fn().mockResolvedValue({
|
||||
id: "proj-1",
|
||||
path: "/tmp/projects/proj-1.md",
|
||||
name: "Updated",
|
||||
description: "",
|
||||
prompt: "",
|
||||
@@ -55,8 +55,6 @@ vi.mock("../../api/projects", () => ({
|
||||
useWorktrees: false,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
}),
|
||||
scanProjectIcons: vi.fn().mockResolvedValue([]),
|
||||
readProjectIcon: vi.fn().mockResolvedValue({
|
||||
@@ -93,6 +91,7 @@ vi.mock("../PromptEditor", () => ({
|
||||
function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "proj-1",
|
||||
path: "/tmp/projects/proj-1.md",
|
||||
name: "My Project",
|
||||
description: "A test project",
|
||||
prompt: "Do the thing",
|
||||
@@ -104,8 +103,6 @@ function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
useWorktrees: false,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("listSkills", () => {
|
||||
name: "code-review",
|
||||
description: "Reviews code",
|
||||
content: "Review carefully",
|
||||
directory: "/Users/test/.agents/skills/code-review",
|
||||
path: "/Users/test/.agents/skills/code-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
@@ -38,7 +38,7 @@ describe("listSkills", () => {
|
||||
name: "code-review",
|
||||
description: "Reviews code",
|
||||
content: "Review carefully",
|
||||
directory: "/Users/test/.agents/skills/code-review",
|
||||
path: "/Users/test/.agents/skills/code-review",
|
||||
global: true,
|
||||
},
|
||||
{
|
||||
@@ -46,7 +46,7 @@ describe("listSkills", () => {
|
||||
name: "test-writer",
|
||||
description: "Writes tests",
|
||||
content: "Write tests",
|
||||
directory: "/tmp/alpha/.agents/skills/test-writer",
|
||||
path: "/tmp/alpha/.agents/skills/test-writer",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
@@ -97,7 +97,7 @@ describe("listSkills", () => {
|
||||
name: "legacy-writer",
|
||||
description: "Legacy project skill",
|
||||
content: "Legacy instructions",
|
||||
directory: "/tmp/beta/.goose/skills/legacy-writer",
|
||||
path: "/tmp/beta/.goose/skills/legacy-writer",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
@@ -133,7 +133,7 @@ describe("listSkills", () => {
|
||||
name: "code-review",
|
||||
description: "Reviews code",
|
||||
content: "Review carefully",
|
||||
directory: "/Users/test/.agents/skills/code-review",
|
||||
path: "/Users/test/.agents/skills/code-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
@@ -147,7 +147,7 @@ describe("listSkills", () => {
|
||||
name: "test-writer",
|
||||
description: "Writes tests",
|
||||
content: "Write tests",
|
||||
directory: "/tmp/beta/.agents/skills/test-writer",
|
||||
path: "/tmp/beta/.agents/skills/test-writer",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
@@ -172,7 +172,7 @@ describe("listSkills", () => {
|
||||
name: "code-review",
|
||||
description: "Reviews code",
|
||||
content: "Review carefully",
|
||||
directory: "/Users/test/.agents/skills/code-review",
|
||||
path: "/Users/test/.agents/skills/code-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
@@ -185,7 +185,7 @@ describe("listSkills", () => {
|
||||
name: "test-writer",
|
||||
description: "Writes tests",
|
||||
content: "Write tests",
|
||||
directory: "/tmp/alpha/.agents/skills/test-writer",
|
||||
path: "/tmp/alpha/.agents/skills/test-writer",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
@@ -219,7 +219,7 @@ describe("listSkills", () => {
|
||||
name: "personal-review",
|
||||
description: "Reviews personal code",
|
||||
content: "Review local changes",
|
||||
directory: "/Users/test/.agents/skills/personal-review",
|
||||
path: "/Users/test/.agents/skills/personal-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
@@ -231,7 +231,7 @@ describe("listSkills", () => {
|
||||
name: "goose-doc-guide",
|
||||
description: "Goose documentation guide",
|
||||
content: "Use Goose docs",
|
||||
directory: "builtin://skills/goose-doc-guide",
|
||||
path: "builtin://skills/goose-doc-guide",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
@@ -243,7 +243,7 @@ describe("listSkills", () => {
|
||||
name: "project-helper",
|
||||
description: "Helps project work",
|
||||
content: "Use project context",
|
||||
directory: "/tmp/alpha/.agents/skills/project-helper",
|
||||
path: "/tmp/alpha/.agents/skills/project-helper",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -62,8 +62,8 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
instructions: source.content,
|
||||
path: source.directory,
|
||||
fileLocation: source.directory,
|
||||
path: source.path,
|
||||
fileLocation: source.path,
|
||||
sourceKind: "builtin",
|
||||
sourceLabel: "Built in",
|
||||
projectLinks: [],
|
||||
@@ -71,10 +71,21 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
|
||||
}
|
||||
|
||||
const sourceKind: SkillSourceKind = source.global ? "global" : "project";
|
||||
const props = (source.properties ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Backend tags project-scoped skills with these when listing via
|
||||
// include_project_sources. Prefer them over path-derived values so badges
|
||||
// show the user-visible project title.
|
||||
const taggedProjectDir =
|
||||
typeof props.projectDir === "string" ? props.projectDir : null;
|
||||
const taggedProjectName =
|
||||
typeof props.projectName === "string" ? props.projectName : null;
|
||||
|
||||
const projectRoot = source.global
|
||||
? null
|
||||
: deriveProjectRoot(source.directory);
|
||||
const projectName = projectRoot ? basename(projectRoot) : "";
|
||||
: (taggedProjectDir ?? deriveProjectRoot(source.path));
|
||||
const projectName =
|
||||
taggedProjectName ?? (projectRoot ? basename(projectRoot) : "");
|
||||
|
||||
const projectLinks: SkillProjectLink[] = projectRoot
|
||||
? [
|
||||
@@ -87,12 +98,12 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: `${sourceKind}:${source.directory}`,
|
||||
id: `${sourceKind}:${source.path}`,
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
instructions: source.content,
|
||||
path: source.directory,
|
||||
fileLocation: getSkillFileLocation(source.directory),
|
||||
path: source.path,
|
||||
fileLocation: getSkillFileLocation(source.path),
|
||||
sourceKind,
|
||||
sourceLabel:
|
||||
sourceKind === "global" ? "Personal" : projectName || "Project",
|
||||
@@ -104,10 +115,17 @@ function uniqueProjectDirs(projectDirs: string[]) {
|
||||
return [...new Set(projectDirs.map((dir) => dir.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export interface CreateSkillOptions {
|
||||
/** Project source ID (kebab slug). When set, the skill is created under
|
||||
* that project's first working directory. */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export async function createSkill(
|
||||
name: string,
|
||||
description: string,
|
||||
instructions: string,
|
||||
options: CreateSkillOptions = {},
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSourcesCreate({
|
||||
@@ -115,7 +133,8 @@ export async function createSkill(
|
||||
name,
|
||||
description,
|
||||
content: instructions,
|
||||
global: true,
|
||||
global: !options.projectId,
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +183,7 @@ export async function listSkills(
|
||||
const key =
|
||||
source.type === BUILTIN_SKILL_SOURCE_TYPE
|
||||
? `builtin:${source.name}`
|
||||
: `${source.global ? "global" : "project"}:${source.directory}`;
|
||||
: `${source.global ? "global" : "project"}:${source.path}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -11,6 +11,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
@@ -20,6 +28,9 @@ import {
|
||||
import { formatSkillName, isValidSkillName } from "../lib/skillsHelpers";
|
||||
import { getRenamedSkillFileLocation } from "../lib/skillsPath";
|
||||
|
||||
/** Sentinel value for the "Global" option in the save-location picker. */
|
||||
const GLOBAL_VALUE = "__global__";
|
||||
|
||||
interface SkillEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -37,9 +48,18 @@ export function SkillEditor({
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [saveLocation, setSaveLocation] = useState(GLOBAL_VALUE);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const projects = useProjectStore((s) => s.projects);
|
||||
|
||||
// Only projects with working directories can hold skills
|
||||
const projectsWithDirs = useMemo(
|
||||
() => projects.filter((p) => p.workingDirs.length > 0),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const isEditing = !!editingSkill;
|
||||
|
||||
// Pre-fill fields when editing
|
||||
@@ -48,11 +68,13 @@ export function SkillEditor({
|
||||
setName(editingSkill.name);
|
||||
setDescription(editingSkill.description);
|
||||
setInstructions(editingSkill.instructions);
|
||||
setSaveLocation(GLOBAL_VALUE); // location is fixed for existing skills
|
||||
setError(null);
|
||||
} else if (isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setInstructions("");
|
||||
setSaveLocation(GLOBAL_VALUE);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, editingSkill]);
|
||||
@@ -69,6 +91,7 @@ export function SkillEditor({
|
||||
setName("");
|
||||
setDescription("");
|
||||
setInstructions("");
|
||||
setSaveLocation(GLOBAL_VALUE);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
@@ -88,11 +111,16 @@ export function SkillEditor({
|
||||
instructions,
|
||||
);
|
||||
} else {
|
||||
await createSkill(name, description.trim(), instructions);
|
||||
const projectId =
|
||||
saveLocation !== GLOBAL_VALUE ? saveLocation : undefined;
|
||||
await createSkill(name, description.trim(), instructions, {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
setName("");
|
||||
setDescription("");
|
||||
setInstructions("");
|
||||
setSaveLocation(GLOBAL_VALUE);
|
||||
await onSaved?.(savedSkill);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -133,6 +161,35 @@ export function SkillEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save location — only shown when creating */}
|
||||
{!isEditing && projectsWithDirs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t("dialog.saveLocation")}
|
||||
</Label>
|
||||
<Select value={saveLocation} onValueChange={setSaveLocation}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={GLOBAL_VALUE}>
|
||||
{t("dialog.global")}
|
||||
</SelectItem>
|
||||
{projectsWithDirs.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{saveLocation === GLOBAL_VALUE
|
||||
? t("dialog.globalHint")
|
||||
: t("dialog.projectHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -244,6 +244,7 @@ describe("SkillEditor", () => {
|
||||
"my-skill",
|
||||
"A description",
|
||||
"Some instructions",
|
||||
{ projectId: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What it does and when to use it...",
|
||||
"editTitle": "Edit skill",
|
||||
"global": "Global",
|
||||
"globalHint": "Available to all sessions",
|
||||
"instructions": "Instructions",
|
||||
"instructionsPlaceholder": "Markdown instructions the agent will follow...",
|
||||
"name": "Name",
|
||||
@@ -12,6 +14,8 @@
|
||||
"nameValidation": "Use 1–64 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
|
||||
"pathOnDisk": "Path on disk",
|
||||
"newTitle": "New skill",
|
||||
"projectHint": "Stored in the project folder",
|
||||
"saveLocation": "Save to",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"view": {
|
||||
|
||||
Reference in New Issue
Block a user