Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/wise-projects-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"t3code-cli": minor
---

Add `project update` for editing project metadata.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pnpm sync-upstream --target stable
```sh
t3cli project list # List known projects
t3cli project add [--path <path>] [--title <title>]
t3cli project update [--project <ref>] [--title <title>] [--workspace-root <path>] [--provider <id>] [--model <slug>] [--option <key=value>] [--reasoning-effort <value>] [--effort <value>] [--fast-mode | --no-fast-mode] [--thinking | --no-thinking] [--clear-default-model] [--thread-env local|worktree | --clear-thread-env] [--favicon <path> | --clear-favicon] [--format auto|human|json]
t3cli project delete [--project <ref>] [--force] [--yes]
```

Expand Down
14 changes: 7 additions & 7 deletions src/application/project-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,19 @@ export const makeProjectDeleteCommand = Effect.fn("makeProjectDeleteCommand")(fu
export const makeProjectMetaUpdateCommand = Effect.fn("makeProjectMetaUpdateCommand")(
function* (input: {
readonly projectId: string;
readonly scripts: Extract<
ClientOrchestrationCommand,
{ readonly type: "project.meta.update" }
>["scripts"];
}) {
} & Omit<
Extract<ClientOrchestrationCommand, { readonly type: "project.meta.update" }>,
"type" | "commandId" | "projectId"
>) {
const crypto = yield* Crypto.Crypto;
const { projectId, ...patch } = input;
return {
type: "project.meta.update",
commandId: CommandId.make(
`t3cli:project-meta-update:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
),
projectId: ProjectId.make(input.projectId),
scripts: input.scripts,
projectId: ProjectId.make(projectId),
...patch,
} satisfies Extract<ClientOrchestrationCommand, { readonly type: "project.meta.update" }>;
},
);
85 changes: 83 additions & 2 deletions src/application/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@

import { CliRuntime } from "../cli/runtime/service.ts";
import { T3Orchestration } from "../orchestration/service.ts";
import { ProjectCreateVisibilityError, ProjectLookupError } from "../domain/error.ts";
import {
ProjectCreateVisibilityError,
ProjectLookupError,
ProjectUpdateValidationError,
} from "../domain/error.ts";
import { findProjectById, resolveProjectScope } from "../domain/helpers.ts";
import { makeProjectCreateCommand, makeProjectDeleteCommand } from "./project-commands.ts";
import {
makeProjectCreateCommand,
makeProjectDeleteCommand,
makeProjectMetaUpdateCommand,
} from "./project-commands.ts";
import { resolveModelSelection } from "./model-selection.ts";
import { waitForShellSequence } from "./shell-sequence.ts";
import type { T3ProjectApplicationService } from "./service.ts";

Expand Down Expand Up @@ -69,11 +78,83 @@
const dispatch = yield* orchestration.dispatch(command);
return { projectId: input.projectId, dispatch };
});
const updateProject: T3ProjectApplicationService["updateProject"] = Effect.fn(
"T3ApplicationLive.updateProject",
)(function* (input) {
const project = yield* resolveProject(input.projectRef);
let workspaceRoot = input.workspaceRoot;
if (workspaceRoot !== undefined) {
if (!input.local && !path.isAbsolute(workspaceRoot)) {
return yield* Effect.fail(
new ProjectUpdateValidationError({
message: "--workspace-root must be absolute for a remote environment",
projectId: project.id,
}),
);
}
workspaceRoot = input.local
? path.resolve(cliRuntime.cwd, workspaceRoot)
: path.normalize(workspaceRoot);
}
if (input.favicon !== undefined && input.favicon !== null) {
const supported = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(input.favicon);
if (
path.isAbsolute(input.favicon) ||
input.favicon.split(/[\\/]/).includes("..") ||
!supported
) {
return yield* Effect.fail(
new ProjectUpdateValidationError({
message: "--favicon must be a workspace-relative supported image path",
projectId: project.id,
}),
);
}
}
let defaultModelSelection = input.defaultModelSelection;
if (input.provider !== undefined || input.model !== undefined || input.options !== undefined) {
defaultModelSelection = yield* resolveModelSelection({

Check failure on line 116 in src/application/projects.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(TS2322)

Type '{ readonly instanceId: string & Brand<"ProviderInstanceId">; readonly model: string; readonly options?: readonly { readonly id: string; readonly value: string | boolean; }[]; }' is not assignable to type 'null | undefined'.

Check failure on line 116 in src/application/projects.ts

View workflow job for this annotation

GitHub Actions / typecheck

Type '{ readonly instanceId: string & Brand<"ProviderInstanceId">; readonly model: string; readonly options?: readonly { readonly id: string; readonly value: string | boolean; }[]; }' is not assignable to type 'null | undefined'.
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the provider during model-only updates

When a project already defaults to provider B, running project update --model <slug> passes the partial update through resolveModelSelection, whose model-only branch selects the first available provider rather than retaining B. This silently changes both fields and can persist a provider/model combination that is incompatible. Partial edits should resolve from the project's existing default selection, as thread metadata updates do, while using the first available provider only when no project default exists.

Useful? React with 👍 / 👎.

start: {
message: "",
...(input.provider !== undefined ? { provider: input.provider } : {}),
...(input.model !== undefined ? { model: input.model } : {}),
...(input.options !== undefined ? { options: input.options } : {}),
},
project,
serverConfig: yield* orchestration.getServerConfig(),
});
}
const command = yield* makeProjectMetaUpdateCommand({
projectId: project.id,
...(input.title !== undefined ? { title: input.title } : {}),
...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
...(defaultModelSelection !== undefined ? { defaultModelSelection } : {}),
...(input.defaultThreadEnvironment !== undefined
? { defaultThreadEnvironment: input.defaultThreadEnvironment }
: {}),
...(input.favicon !== undefined ? { favicon: input.favicon } : {}),
}).pipe(Effect.provideService(Crypto.Crypto, crypto));
const dispatch = yield* orchestration.dispatch(command);
const snapshot = yield* waitForShellSequence({ sequence: dispatch.sequence }).pipe(
Effect.provideService(T3Orchestration, orchestration),
);
const updated = findProjectById(snapshot, project.id);
if (updated === null) {
return yield* Effect.fail(
new ProjectLookupError({
message: `project not found after update: ${project.id}`,
ref: project.id,
}),
);
}
return { dispatch, project: updated };
});

return {
loadShell,
addProject,
resolveProject,
updateProject,
deleteProject,
} satisfies T3ProjectApplicationService;
});
15 changes: 15 additions & 0 deletions src/application/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,21 @@ export type T3ProjectApplicationService = {
readonly resolveProject: (
projectRef: string,
) => Effect.Effect<OrchestrationProjectShell, ApplicationError>;
readonly updateProject: (input: {
readonly projectRef: string;
readonly title?: string;
readonly workspaceRoot?: string;
readonly local: boolean;
readonly provider?: string;
readonly model?: string;
readonly options?: NonNullable<ModelSelection["options"]>;
readonly defaultModelSelection?: null;
readonly defaultThreadEnvironment?: "local" | "worktree" | null;
readonly favicon?: string | null;
}) => Effect.Effect<
{ readonly dispatch: DispatchResult; readonly project: OrchestrationProjectShell },
ApplicationError
>;
readonly deleteProject: (input: {
readonly projectId: string;
readonly force?: boolean;
Expand Down
8 changes: 8 additions & 0 deletions src/cli/format/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export function formatProjectAddedHuman(project: OrchestrationProjectShell) {
])}`;
}

export function formatProjectUpdatedHuman(project: OrchestrationProjectShell) {
return `project updated\n${formatRecord([
{ field: "title", value: project.title },
{ field: "id", value: project.id },
{ field: "path", value: project.workspaceRoot },
])}`;
}

export function formatProjectDeletedHuman(input: {
readonly projectId: string;
readonly dispatch: { readonly sequence: number };
Expand Down
3 changes: 2 additions & 1 deletion src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { extraArgsConfig } from "./extra-args.ts";
import { formatFlag, projectPathFlag } from "./flags.ts";
import { formatProjectAddedHuman, formatProjectsHuman } from "./format/project.ts";
import { deleteProjectCommand } from "./projects/delete.ts";
import { updateProjectCommand } from "./projects/update.ts";
import { T3Application } from "../application/service.ts";
import { CliRuntime } from "../cli/runtime/service.ts";
import { loadT3CliEnv } from "../config/env/env.ts";
Expand All @@ -15,7 +16,7 @@ import { T3Output } from "./output/service.ts";
export function createProjectCommand() {
return Command.make("project").pipe(
Command.withDescription("project commands"),
Command.withSubcommands([listCommand, addCommand, deleteProjectCommand]),
Command.withSubcommands([listCommand, addCommand, updateProjectCommand, deleteProjectCommand]),
);
}

Expand Down
90 changes: 90 additions & 0 deletions src/cli/projects/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import "vite-plus/test/config";

import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Option from "effect/Option";
import { assert, describe, it } from "@effect/vitest";
import { Command } from "effect/unstable/cli";
import { fromPartial } from "@total-typescript/shoehorn";

import { T3Application } from "../../application/service.ts";
import { T3Config } from "../../config/config.ts";
import { t3CliEnvConfigLayer } from "../../config/env/env.test-utils.ts";
import * as CliRuntime from "../runtime/service.ts";
import { T3Output } from "../output/service.ts";
import { updateProjectCommand } from "./update.ts";

const testLayer = Layer.mergeAll(
Layer.succeed(
T3Application,
fromPartial({ updateProject: () => Effect.die("updateProject should not be called") }),
),
Layer.succeed(
T3Config,
fromPartial({
resolve: () =>
Effect.succeed({ url: "ws://localhost", token: "token", source: "config", local: true }),
}),
),
Layer.succeed(T3Output, {
writeStdout: () => Effect.void,
writeStderr: () => Effect.void,
printJson: () => Effect.void,
printNdjson: () => Effect.void,
printInfo: () => Effect.void,
}),
NodeServices.layer,
CliRuntime.layer,
t3CliEnvConfigLayer("/tmp/t3cli-test"),
);

describe("updateProjectCommand", () => {
it.layer(testLayer)("validation", (t) => {
const run = Command.runWith(updateProjectCommand, { version: "0.0.0-test" });

t.effect("requires at least one update", () =>
expectError(run(["--project", "proj-1"]), "MissingUpdateFieldsError"),
);

t.effect("rejects a default model value with its clear flag", () =>
expectError(
run(["--project", "proj-1", "--model", "gpt-5", "--clear-default-model"]),
"ConflictingUpdateFlagsError",
),
);

t.effect("rejects a favicon value with its clear flag", () =>
expectError(
run(["--project", "proj-1", "--favicon", "icon.png", "--clear-favicon"]),
"ConflictingUpdateFlagsError",
),
);

t.effect("rejects a thread environment value with its clear flag", () =>
expectError(
run(["--project", "proj-1", "--thread-env", "local", "--clear-thread-env"]),
"ConflictingUpdateFlagsError",
),
);
});
});

function expectError<R>(
effect: Effect.Effect<unknown, unknown, R>,
expectedTag: string,
) {
return Effect.gen(function* () {
const exit = yield* effect.pipe(Effect.exit);
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
const error = Cause.findErrorOption(exit.cause);
assert.isTrue(Option.isSome(error));
if (Option.isSome(error)) {
assert.equal((error.value as { readonly _tag?: string })._tag, expectedTag);

Check failure on line 86 in src/cli/projects/update.test.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: type '{ readonly _tag?: string; }' is more narrow than the original type.

Check failure on line 86 in src/cli/projects/update.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_tag`'.
}
}
});
}
105 changes: 105 additions & 0 deletions src/cli/projects/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { Command, Flag } from "effect/unstable/cli";

import { T3Application } from "../../application/service.ts";
import { CliRuntime } from "../runtime/service.ts";
import { T3Config } from "../../config/config.ts";
import { loadT3CliEnv } from "../../config/env/env.ts";
import { extraArgsConfig } from "../extra-args.ts";
import { ConflictingUpdateFlagsError, MissingUpdateFieldsError } from "../error.ts";
import { formatFlag, modelFlags, projectFlag } from "../flags.ts";
import { formatProjectUpdatedHuman } from "../format/project.ts";
import { resolveOutputFormat } from "../format/output.ts";
import { buildModelOptions } from "../model-options.ts";
import { T3Output } from "../output/service.ts";
import { requireCommandProjectRef } from "../require.ts";

export const updateProjectCommand = Command.make(
"update",
{
project: projectFlag,
title: Flag.string("title").pipe(Flag.optional),
workspaceRoot: Flag.string("workspace-root").pipe(Flag.optional),
provider: Flag.string("provider").pipe(Flag.optional),
model: Flag.string("model").pipe(Flag.optional),
...modelFlags,
clearDefaultModel: Flag.boolean("clear-default-model").pipe(Flag.optional),
threadEnv: Flag.choice("thread-env", ["local", "worktree"] as const).pipe(Flag.optional),
clearThreadEnv: Flag.boolean("clear-thread-env").pipe(Flag.optional),
favicon: Flag.string("favicon").pipe(Flag.optional),
clearFavicon: Flag.boolean("clear-favicon").pipe(Flag.optional),
format: formatFlag,
...extraArgsConfig,
},
(flags) =>
Effect.gen(function* () {
const title = Option.getOrUndefined(flags.title);
const workspaceRoot = Option.getOrUndefined(flags.workspaceRoot);
const provider = Option.getOrUndefined(flags.provider);
const model = Option.getOrUndefined(flags.model);
const threadEnv = Option.getOrUndefined(flags.threadEnv);
const favicon = Option.getOrUndefined(flags.favicon);
const clearDefaultModel = Option.getOrUndefined(flags.clearDefaultModel) === true;
const clearThreadEnv = Option.getOrUndefined(flags.clearThreadEnv) === true;
const clearFavicon = Option.getOrUndefined(flags.clearFavicon) === true;
const options = buildModelOptions(flags);
const hasModel = provider !== undefined || model !== undefined || options.length > 0;

if (hasModel && clearDefaultModel) {
return yield* conflict("model flags and --clear-default-model are mutually exclusive");
}
if (threadEnv !== undefined && clearThreadEnv) {
return yield* conflict("--thread-env and --clear-thread-env are mutually exclusive");
}
if (favicon !== undefined && clearFavicon) {
return yield* conflict("--favicon and --clear-favicon are mutually exclusive");
}
if (
title === undefined &&
workspaceRoot === undefined &&
!hasModel &&
!clearDefaultModel &&
threadEnv === undefined &&
!clearThreadEnv &&
favicon === undefined &&
!clearFavicon
) {
return yield* Effect.fail(
new MissingUpdateFieldsError({
message: "at least one project metadata update field is required",
}),
);
}

const application = yield* T3Application;
const config = yield* T3Config;
const runtime = yield* CliRuntime;
const env = yield* loadT3CliEnv;
const output = yield* T3Output;
const result = yield* application.updateProject({
projectRef: yield* requireCommandProjectRef({ project: flags.project }),
local: (yield* config.resolve()).local,
...(title !== undefined ? { title } : {}),
...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
...(provider !== undefined ? { provider } : {}),
...(model !== undefined ? { model } : {}),
...(options.length > 0 ? { options } : {}),
...(clearDefaultModel ? { defaultModelSelection: null } : {}),
...(clearThreadEnv
? { defaultThreadEnvironment: null }
: threadEnv !== undefined
? { defaultThreadEnvironment: threadEnv }
: {}),
...(clearFavicon ? { favicon: null } : favicon !== undefined ? { favicon } : {}),
});
if (resolveOutputFormat(flags.format, runtime, env, "json") === "json") {
return yield* output.printJson(result.project);
}
return yield* output.printInfo(formatProjectUpdatedHuman(result.project));
}),
).pipe(Command.withDescription("update project metadata"));

function conflict(message: string) {
return Effect.fail(new ConflictingUpdateFlagsError({ message }));
}
Loading
Loading