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
21 changes: 13 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ jobs:
& $env:GODOT --headless --path . --script res://tests/site_commands.gd
if ($LASTEXITCODE -ne 0) { throw 'The page and the Connect dialog differ.' }
- name: Package
run: ./tools/package.ps1 -Godot $env:GODOT
run: ./tools/package.ps1 -Godot $env:GODOT -Release ${{ github.run_number }}
- name: Run the packaged app
run: ./tests/package_smoke.ps1 -Zip build/AgentGuildStudio-windows.zip -Dir .smoke
run: ./tests/package_smoke.ps1 -Zip build/AgentGuildStudio-windows.zip -Dir .smoke -Release ${{ github.run_number }}
- id: zip
if: github.event_name == 'push'
run: Add-Content $env:GITHUB_OUTPUT "sha256=$((Get-FileHash build/AgentGuildStudio-windows.zip).Hash.ToLower())"
Expand Down Expand Up @@ -184,7 +184,7 @@ jobs:
Compress-Archive -LiteralPath "$env:RUNNER_TEMP/stage/AgentGuildStudio" -DestinationPath $zip
Add-Content $env:GITHUB_OUTPUT "sha256=$((Get-FileHash $zip).Hash.ToLower())"
- name: Run the signed app
run: ./tests/package_smoke.ps1 -Zip "$env:RUNNER_TEMP/signed/AgentGuildStudio-windows.zip" -Dir "$env:RUNNER_TEMP/smoke"
run: ./tests/package_smoke.ps1 -Zip "$env:RUNNER_TEMP/signed/AgentGuildStudio-windows.zip" -Dir "$env:RUNNER_TEMP/smoke" -Release ${{ github.run_number }}
- name: Check the signature
env:
PUBLISHER: ${{ vars.WINDOWS_PUBLISHER }}
Expand Down Expand Up @@ -274,7 +274,7 @@ jobs:
& $env:GODOT --headless --path . --script res://tests/site_commands.gd
if ($LASTEXITCODE -ne 0) { throw 'The page and the Connect dialog differ.' }
- name: Package
run: ./tools/package.ps1 -Godot $env:GODOT -Platform macOS
run: ./tools/package.ps1 -Godot $env:GODOT -Platform macOS -Release ${{ github.run_number }}
- name: Run the packaged app
run: |
ditto -x -k build/AgentGuildStudio-macos.zip .smoke
Expand All @@ -284,8 +284,10 @@ jobs:
if (-not [IO.Path]::GetExtension($iconFile)) { $iconFile += '.icns' }
if ((Get-FileHash ".smoke/AgentGuildStudio.app/Contents/Resources/$iconFile").Hash -ne (Get-FileHash brand/icon.icns).Hash) { throw 'The packaged app does not use brand/icon.icns.' }
$exe = '.smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio'
$version = & $exe --headless --no-header -- --version
if ($LASTEXITCODE -ne 0 -or -not $version) { throw 'The packaged app did not start.' }
$version = '0.1.${{ github.run_number }}'
$said = & $exe --headless --no-header -- --version
$short = & /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' .smoke/AgentGuildStudio.app/Contents/Info.plist
if ($LASTEXITCODE -ne 0 -or "$said" -ne $version -or $short -ne $version) { throw "The packaged app says it is $said ($short), not $version." }
node tests/mcp_smoke.mjs $exe
if ($LASTEXITCODE -ne 0) { throw 'The packaged app did not serve MCP.' }
- name: Signing checks
Expand Down Expand Up @@ -347,8 +349,11 @@ jobs:
run: |
ditto -x -k "$RUNNER_TEMP/signed/AgentGuildStudio-macos.zip" "$RUNNER_TEMP/smoke"
exe="$RUNNER_TEMP/smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio"
if [ -z "$("$exe" --headless --no-header -- --version)" ]; then
echo "::error::The signed app did not start."
version="0.1.${{ github.run_number }}"
said="$("$exe" --headless --no-header -- --version)"
short="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNNER_TEMP/smoke/AgentGuildStudio.app/Contents/Info.plist")"
if [ "$said" != "$version" ] || [ "$short" != "$version" ]; then
echo "::error::The signed app says it is $said ($short), not $version."
exit 1
fi
node tests/mcp_smoke.mjs "$exe"
Expand Down
1 change: 0 additions & 1 deletion project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ config/name="Agent Guild Studio"
config/use_custom_user_dir=true
config/custom_user_dir_name="AgentGuildStudio"
config/description="Make characters and companions for Agent Guild."
config/version="0.0.0"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
boot_splash/bg_color=Color(0.043, 0.039, 0.059, 1)
Expand Down
2 changes: 1 addition & 1 deletion src/app/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func _ready() -> void:
var parts := arg.trim_prefix("--").split("=", true, 1)
options[parts[0]] = parts[1] if parts.size() > 1 else "1"
if options.has("version"):
print(ProjectSettings.get_setting("application/config/version"))
print(Studio.version())
get_tree().quit()
return
if options.has("check-piece") or options.has("check-source"):
Expand Down
10 changes: 6 additions & 4 deletions src/mcp/server.gd
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const SUPPORTED := ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "202
const META_VERSION := "io.modelcontextprotocol/protocolVersion"
const META_CAPABILITIES := "io.modelcontextprotocol/clientCapabilities"
const META_SERVER := "io.modelcontextprotocol/serverInfo"
const SERVER_INFO := {"name": "agent-guild-studio", "title": "Agent Guild: Studio", "version": "1"}
const PARSE_ERROR := -32700
const INVALID_REQUEST := -32600
const METHOD_NOT_FOUND := -32601
Expand All @@ -30,6 +29,9 @@ var _active: Dictionary = {}
var _closed := false
var _left := false

static func server_info() -> Dictionary:
return {"name": "agent-guild-studio", "title": "Agent Guild: Studio", "version": Studio.version()}

func _ready() -> void:
tools = McpTools.new(self)
if read_stdin:
Expand Down Expand Up @@ -168,7 +170,7 @@ func _dispatch(msg: Dictionary) -> void:
return
var asked = params.get("protocolVersion")
_legacy_version = asked if asked in LEGACY else LEGACY[0]
respond(ctx, {"protocolVersion": _legacy_version, "capabilities": {"tools": {}}, "serverInfo": SERVER_INFO, "instructions": INSTRUCTIONS})
respond(ctx, {"protocolVersion": _legacy_version, "capabilities": {"tools": {}}, "serverInfo": server_info(), "instructions": INSTRUCTIONS})
"server/discover":
if not ctx.modern:
_send_error(ctx.id, INVALID_PARAMS, "server/discover needs %s and %s in _meta." % [META_VERSION, META_CAPABILITIES])
Expand Down Expand Up @@ -210,7 +212,7 @@ func respond(ctx: Dictionary, result: Dictionary) -> void:
if ctx.modern:
result["resultType"] = "complete"
var meta: Dictionary = result.get("_meta", {})
meta[META_SERVER] = SERVER_INFO
meta[META_SERVER] = server_info()
result["_meta"] = meta
_send({"jsonrpc": "2.0", "id": ctx.id, "result": result})

Expand All @@ -225,4 +227,4 @@ func _send_error(id: Variant, code: int, message: String) -> void:
_send({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})

func _send(msg: Dictionary) -> void:
write_line.call(JSON.stringify(msg))
write_line.call(StrictJson.stringify(msg))
18 changes: 11 additions & 7 deletions src/mcp/tools.gd
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func failure(text: String, data: Variant = null) -> Dictionary:
return out

func _done(text: String, data: Variant = null) -> Dictionary:
var out := {"content": [{"type": "text", "text": text if data == null else text + "\n" + JSON.stringify(data)}], "isError": false}
var out := {"content": [{"type": "text", "text": text if data == null else text + "\n" + StrictJson.stringify(data)}], "isError": false}
if data != null:
out["structuredContent"] = data
return out
Expand Down Expand Up @@ -148,7 +148,7 @@ func _definitions() -> Array:
"hue": hue, "tint": tint, "change_color": change_color, "spread": spread,
"replace": replace}, ["slot", "look", "to"]), "annotations": writes},
{"name": "check_piece", "title": "Check a piece", "description": "Checks a piece folder (piece.json and its models) as Studio checks the ones it makes, for the world and slot its piece.json names.",
"inputSchema": _object({"folder": _path("The piece's folder, as a full path.")}, ["folder"]), "annotations": read},
"inputSchema": _object({"folder": _path("The piece's folder, as a full path.")}, ["folder"]), "annotations": {"readOnlyHint": true, "openWorldHint": true}},
{"name": "list_pieces", "title": "List pieces", "description": "The pieces made with Studio on this computer, by the person in its window or by an agent: each one's `piece` value, title, slot and the folder it was saved to.",
"inputSchema": _object({}, []), "annotations": read},
{"name": "remove_piece", "title": "Remove a piece", "description": "Takes a piece off Studio's list. The folder it was saved to is left as it is.",
Expand Down Expand Up @@ -225,6 +225,9 @@ func _make(ctx: Dictionary, args: Dictionary, options: Dictionary, recipe: Dicti
return failure("%s is a file, not a folder." % to)
if DirAccess.dir_exists_absolute(to) and not args.get("replace", false) and not (DirAccess.get_files_at(to).is_empty() and DirAccess.get_directories_at(to).is_empty()):
return failure("%s already holds files. Choose another folder, or set `replace`." % to)
var other := StudioExport.other(to)
if other != "":
return failure("%s holds %s, which a piece folder may not. Choose another folder." % [to, other])
if args.has("title"):
options.title = args.title
var where := Studio.slot(options.slot)
Expand All @@ -243,11 +246,12 @@ func _make(ctx: Dictionary, args: Dictionary, options: Dictionary, recipe: Dicti
var library: PieceLibrary = server.host.pieces
library.refresh()
var id := await library.add_local(folder, "", recipe)
var listed: bool = library.pieces.get(id, {}).get("error", "x") == ""
if listed:
library.pieces[id].saved = folder
library.keep()
return _done("Made and checked. The piece is in %s." % folder, {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "piece": id if listed else ""})
var problem: String = library.pieces.get(id, {}).get("error", "it was not listed")
if problem != "":
return failure("The piece in %s was made, but Studio couldn't list it: %s." % [folder, problem], {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "problem": problem})
library.pieces[id].saved = folder
library.keep()
return _done("Made and checked. The piece is in %s." % folder, {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "piece": id})

## The world's art, downloaded if it isn't here, then loaded; "" or why not.
func _art(ctx: Dictionary, world: String) -> String:
Expand Down
2 changes: 1 addition & 1 deletion src/pieces/piece_check.gd
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ static func run(options: Dictionary) -> int:
var watchdog := Thread.new()
watchdog.start(_watch.bind(Time.get_ticks_msec() + int(float(options.get("check-timeout", TIMEOUT)) * 1000) + 5000))
if options.get("check-fault", "") == "crash":
OS.crash("A check fault, for the tests.")
OS.kill(OS.get_process_id())
var verdict: Dictionary
if options.has("check-source"):
verdict = StudioSource.check(options["check-source"], options.get("check-dir", ""))
Expand Down
1 change: 0 additions & 1 deletion src/pieces/piece_validator.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ var pid := -1
var _count := 0
var _out := ""

# stage "load" (no header checks) and fault "crash" are for tests/pieces_test.gd only.
func check(path: String, kind: String, clips: Dictionary, stage := "", fault := "") -> Dictionary:
var args := ["--check-piece=" + ProjectSettings.globalize_path(path), "--check-kind=" + kind,
"--check-clips=" + PieceCheck.clips_arg(clips)]
Expand Down
43 changes: 30 additions & 13 deletions src/studio/studio.gd
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ class_name Studio
## AgentGuildStudio -- --build=ghost.glb[,b.glb,c.glb,d.glb] --slot=grove/companion --out=DIR [--move=rigid|soft] [--variants=tint|turn] [--turn=DEGREES]
## AgentGuildStudio -- --recolour=look0|look1|look2|agent|shell --slot=... --out=DIR [--pick=#RRGGBB|U,V] [--spread=0.15] [--hue=DEGREES] [--tint=#RRGGBB]

## Each world's Studio clip set (tools/premium.py studio): its motions with
## Each world's Studio clip set: its motions with
## both arms free, on a skeleton of its own, for character pieces.
const CLIP_SETS := "res://assets/premium/studio/%s.glb"

static func version() -> String:
var stamped := String(ProjectSettings.get_setting("application/config/version", ""))
return stamped if stamped != "" else "dev"

## { world, kind, crew } for "world/crew" or "world/companion", or { reason }.
static func slot(text: String) -> Dictionary:
var parts := text.split("/")
Expand Down Expand Up @@ -323,29 +327,42 @@ static func run(host: Node, options: Dictionary) -> int:
if made.has("reason"):
return _fail(made.reason)
var models: Array = made.pieces.map(func(p): return StudioExport.scene(p, where.kind))
return await _save(host, where, models, clips, options.get("title", options.build.get_file().get_basename()), made.notes, options)
return await _save(host, where, models, clips, options.get("title", options.build.get_slice(",", 0).get_file().get_basename()), made.notes, options)

## Writes the models as a piece folder, frees them, and checks each as Looks
## would.
static func _save(host: Node, where: Dictionary, models: Array, clips: Dictionary, title: String, notes: Array, options: Dictionary) -> int:
var folder := ProjectSettings.globalize_path(options.get("out", "user://studio/build"))
var folder := ProjectSettings.globalize_path(options.get("out", "user://studio/build")).rstrip("/\\")
var manifest := { "format": 1, "kind": where.kind, "world": where.world, "title": title }
if where.kind == "character":
manifest.crew = where.crew
var reason := StudioExport.save(models, folder, manifest)
var other := StudioExport.other(folder)
if other != "":
for model in models:
model.free()
return _fail("%s holds %s, which a piece may not" % [folder, other])
var part := StudioFiles.make_beside(folder, "part")
var reason := StudioExport.save(models, part, manifest) if part != "" else "a folder to work in could not be made in %s" % folder.get_base_dir()
for model in models:
model.free()
if reason == "":
var validator := PieceValidator.new()
host.add_child(validator)
for k in models.size():
var file: String = PieceLibrary.FILES[where.kind][k]
var verdict: Dictionary = await validator.check(part.path_join(file), where.kind, clips)
if verdict.reason != "":
reason = "%s did not pass its check: %s" % [file, verdict.reason]
break
validator.queue_free()
if reason == "":
reason = PieceLibrary.check_folder(part).get("reason", "")
if reason == "":
reason = StudioFiles.move_in(part, folder, StudioExport.names())
if part != "":
StudioFiles.remove_tree(part)
if reason != "":
return _fail(reason)
var validator := PieceValidator.new()
host.add_child(validator)
for k in models.size():
var file: String = PieceLibrary.FILES[where.kind][k]
var verdict: Dictionary = await validator.check(folder.path_join(file), where.kind, clips)
if verdict.reason != "":
validator.queue_free()
return _fail("%s did not pass its check: %s" % [file, verdict.reason])
validator.queue_free()
said = { "folder": folder, "notes": notes }
if not quiet:
for note in notes:
Expand Down
17 changes: 17 additions & 0 deletions src/studio/studio_export.gd
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ static func write_glb(root: Node, path: String) -> Error:
var err := doc.append_from_scene(root, state)
return doc.write_to_filesystem(state, path) if err == OK else err

static func names() -> Array:
var out: Array = ["piece.json"] + PieceLibrary.PREVIEWS
for kind in PieceLibrary.FILES:
out += PieceLibrary.FILES[kind]
return out

static func other(folder: String) -> String:
if not DirAccess.dir_exists_absolute(folder):
return ""
var dirs := DirAccess.get_directories_at(folder)
if not dirs.is_empty():
return dirs[0]
for file in DirAccess.get_files_at(folder):
if not file in names():
return file
return ""

## A piece folder Looks takes in: its model (a character's one, or a
## companion's four, in order) and piece.json.
static func save(models: Array, folder: String, manifest: Dictionary) -> String:
Expand Down
50 changes: 50 additions & 0 deletions src/studio/studio_files.gd
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,53 @@ static func remove_tree(path: String) -> void:
for name in dir.get_files():
dir.remove(name)
DirAccess.remove_absolute(path)

## A new, empty folder beside `folder` that only the caller holds, or "".
static func make_beside(folder: String, kind: String) -> String:
var parent := folder.get_base_dir()
DirAccess.make_dir_recursive_absolute(parent)
for n in range(1, 1000):
var path := parent.path_join(".%s.%s%s" % [folder.get_file(), "" if n == 1 else "%d." % n, kind])
var err := DirAccess.make_dir_absolute(path)
if err == OK:
return path
if err != ERR_ALREADY_EXISTS:
break
return ""

## Moves the files in `part` into `folder`, first setting aside the files
## `folder` holds of `names`, which go back if any move fails; "" or why not.
static func move_in(part: String, folder: String, names: Array) -> String:
var previous := make_beside(folder, "previous")
if previous == "":
return "a folder to work in could not be made in %s" % folder.get_base_dir()
var made := not DirAccess.dir_exists_absolute(folder)
DirAccess.make_dir_recursive_absolute(folder)
var aside := []
var placed := []
var reason := ""
for name in names:
if FileAccess.file_exists(folder.path_join(name)):
if DirAccess.rename_absolute(folder.path_join(name), previous.path_join(name)) != OK:
reason = "%s could not be set aside" % folder.path_join(name)
break
aside.append(name)
if reason == "":
for name in DirAccess.get_files_at(part):
if DirAccess.rename_absolute(part.path_join(name), folder.path_join(name)) != OK:
reason = "it could not be moved into %s" % folder
break
placed.append(name)
if reason == "":
remove_tree(previous)
return ""
for name in placed:
DirAccess.remove_absolute(folder.path_join(name))
var kept := true
for name in aside:
kept = DirAccess.rename_absolute(previous.path_join(name), folder.path_join(name)) == OK and kept
if kept:
remove_tree(previous)
if made:
DirAccess.remove_absolute(folder)
return reason if kept else "%s; the files it held are in %s" % [reason, previous]
9 changes: 5 additions & 4 deletions src/studio/studio_flow.gd
Original file line number Diff line number Diff line change
Expand Up @@ -385,12 +385,12 @@ func _save(parent: String, title: String) -> void:
panel.working("Saving it in %s…" % parent)
await _shown()
var reason := await _exact()
var part := parent.path_join(".%s.part" % name)
var part := ""
var folder := ""
if reason == "":
_working("Saving it in %s…" % parent)
StudioFiles.remove_tree(part)
reason = _write(part, title)
part = StudioFiles.make_beside(parent.path_join(name), "part")
reason = _write(part, title) if part != "" else "a folder to work in could not be made"
var n := 1
while reason == "" and folder == "":
var free := parent.path_join(name if n == 1 else "%s (%d)" % [name, n])
Expand All @@ -402,7 +402,8 @@ func _save(parent: String, title: String) -> void:
elif not DirAccess.dir_exists_absolute(free):
reason = "it could not be moved into %s" % free
if reason != "":
StudioFiles.remove_tree(part)
if part != "":
StudioFiles.remove_tree(part)
_kept("It could not be saved: %s. Nothing was left in %s." % [reason, parent], true)
return
var id := editing if editing != "" else PieceLibrary.local_id()
Expand Down
2 changes: 2 additions & 0 deletions src/ui/home_panel.gd
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func _build() -> void:
_heading("Agents", false)
_note("An agent can make pieces with Studio for you.")
_button("Connect an agent…", func(): connect_requested.emit())
var version := Studio.version()
_note("Development build" if version == "dev" else "Build " + version.get_slice(".", 2))

func _heading(text: String, first: bool) -> void:
var label := Label.new()
Expand Down
Loading
Loading